diff --git a/.gitattributes b/.gitattributes index 38f15c4b2..156321e53 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,7 +2,9 @@ *.g.cs linguist-generated=true *.g.ts linguist-generated=true *.generated.d.ts linguist-generated=true -src/Coalesce.Web/Scripts/Coalesce/* linguist-generated=true -src/Coalesce.Web/Views/Generated/* linguist-generated=true +playground/**/Scripts/Coalesce/* linguist-generated=true +playground/**/Scripts/Coalesce/**/* linguist-generated=true +playground/**/Views/Generated/* linguist-generated=true +playground/**/Views/Generated/**/* linguist-generated=true * text eol=lf \ No newline at end of file diff --git a/docs/modeling/model-components/attributes/restrict.md b/docs/modeling/model-components/attributes/restrict.md new file mode 100644 index 000000000..21b9826aa --- /dev/null +++ b/docs/modeling/model-components/attributes/restrict.md @@ -0,0 +1,39 @@ +# [Restrict] + +In addition to [role-based](/modeling/model-components/attributes/security-attribute.md) property restrictions, you can also define property restrictions that can execute custom code for each model instance if your logic require more nuanced decisions than can be made with roles. + +``` c#:no-line-numbers +using IntelliTect.Coalesce.DataAnnotations; +public class Employee +{ + public int Id { get; set; } + + [Read] + public string UserId { get; set; } + + [Restrict] + public decimal Salary { get; set; } +} + +public class SalaryRestriction(MyUserService userService) : IPropertyRestriction +{ + public bool UserCanRead(IMappingContext context, string propertyName, Employee model) + => context.User.GetUserId() == model.UserId || userService.IsPayroll(context.User); + + public bool UserCanWrite(IMappingContext context, string propertyName, Employee model, object incomingValue) + => userService.IsPayroll(context.User); + + public bool UserCanFilter(IMappingContext context, string propertyName) + => userService.IsPayroll(context.User); +} +``` + +Restriction classes support dependency injection, so you can inject any supplemental services needed to make a determination. + +The `UserCanRead` method controls whether values of the restricted property will be mapped from model instances to the generated DTO. Similarly, `UserCanWrite` controls whether the property can be mapped back to the model instance from the generated DTO. + +The `UserCanFilter` method has a default implementation that returns `false`, but can be implemented if there is an appropriate, instance-agnostic way to determine if a user can sort, search, or filter values of that property. + +Multiple different restrictions can be placed on a single property; all of them must succeed for the operation to be permitted. Restrictions also stack on top of role attribute restrictions (`[Read]` and `[Edit]`). + +A non-generic variant of `IPropertyRestriction` also exists for restrictions that might be reused across multiple model types. \ No newline at end of file diff --git a/docs/modeling/model-components/properties.md b/docs/modeling/model-components/properties.md index 1d6d6ecdf..3e79e3387 100644 --- a/docs/modeling/model-components/properties.md +++ b/docs/modeling/model-components/properties.md @@ -52,4 +52,4 @@ Properties will be ignored if received by the client if authorization checks aga The [Default Loading Behavior](/modeling/model-components/data-sources.md#default-loading-behavior), any custom functionality defined in [Data Sources](/modeling/model-components/data-sources.md), and [[DtoIncludes] & [DtoExcludes]](/modeling/model-components/attributes/dto-includes-excludes.md) may also restrict which properties are sent to the client when requested. ### NotMapped -While Coalesce does not do anything special for the `[NotMapped]` attribute, it is still and important attribute to keep in mind while building your model, as it prevents EF Core from doing anything with the property. \ No newline at end of file +While Coalesce does not do anything special for the `[NotMapped]` attribute, it is still an important attribute to keep in mind while building your model, as it prevents EF Core from doing anything with the property. \ No newline at end of file diff --git a/docs/stacks/agnostic/dtos.md b/docs/stacks/agnostic/dtos.md index d057d59fd..a2808100d 100644 --- a/docs/stacks/agnostic/dtos.md +++ b/docs/stacks/agnostic/dtos.md @@ -16,5 +16,5 @@ Every class that is exposed through Coalesce's generated API will have a corresp The [[Read] and [Edit] attributes](/modeling/model-components/attributes/security-attribute.md) can be used to apply property-level security, which manifests as conditional logic in the mapping methods on the generated DTOs. -See the [Security](/topics/security.md#attributes) page to read more about property-level security, as well as all other security mechanisms in Coalesce. +See the [Security](/topics/security.md#property-column-security) page to read more about property-level security, as well as all other security mechanisms in Coalesce. diff --git a/docs/topics/audit-logging.md b/docs/topics/audit-logging.md index 89db7a777..e3baa43f0 100644 --- a/docs/topics/audit-logging.md +++ b/docs/topics/audit-logging.md @@ -36,7 +36,7 @@ public class AuditLog : DefaultAuditLog } ``` -This entity only needs to implement `IAuditLog`, but a default implementation of this interface `DefaultAuditLog` is provided for your convenience. `DefaultAuditLog` contains additional properties `ClientIp`, `Referrer`, and `Endpoint` for recording information about the HTTP request (if available), and also has attributes to disable Create, Edit, and Delete APIs. +This entity only needs to implement `IAuditLog`, but a default implementation of this interface `DefaultAuditLog` is provided for your convenience. `DefaultAuditLog` contains additional properties `ClientIp`, `Referrer`, and `Endpoint` for recording information about the HTTP request (if available), and also comes pre-configured for security with Create, Edit, and Delete APIs disabled. You should further augment this type with any additional properties that you would like to track on each change record. A property to track the user who performed the change should be added, since it is not provided by the default implementation so that you can declare it yourself with the correct type for the foreign key and navigation property. diff --git a/docs/topics/security.md b/docs/topics/security.md index 410c48e24..6af165bf7 100644 --- a/docs/topics/security.md +++ b/docs/topics/security.md @@ -1,9 +1,26 @@ # Security -This page is a comprehensive overview of all the techniques that can be used in a Coalesce application to restrict the usage of API endpoints that Coalesce generates. +This page is a comprehensive overview of all the techniques that can be used in a Coalesce application to restrict the capabilities of API endpoints that Coalesce generates. + +The following table is a quick reference of scenarios you might encounter and how you might handle them. If you're unfamiliar with these techniques, though, then you are encouraged to read through this page to get a deeper understanding of what's available before selecting a solution. + +|
When I want to...
| ... I should use ... | +| - | - | +| Remove an entity CRUD operation | [Class Security Attributes](#class-security-attributes) with `DenyAll` | +| Restrict an entity CRUD operation with roles | [Class Security Attributes](#class-security-attributes) | +| Restrict a method or service with roles | [Method Security Attributes](#method-security-attributes) | +| Remove a property from Coalesce | • annotate with [[InternalUse]](#internal-properties)
• `internal` access modifier | +| Restrict a property by roles | [Property Security Attributes](#role-restrictions) | +| Restrict a property with custom logic | • save operations: [custom Behaviors](#behaviors)
• nav prop loading: [custom Default Data Source](#data-sources)
• any property: [custom Property Restrictions](#custom-restrictions) | +| Make a property read-only | • make the setter `internal`
• add a `[Read]` attribute without `[Edit]`
• [other techniques](#read-only-properties) | +| Make a property write-once (init-only) | use an [`init`-only setter](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/init) | +| Prevent [auto-include](/modeling/model-components/data-sources.md#default-loading-behavior) | annotate the navigation property or the included type's class with `[Read(NoAutoInclude = true)]` | +| Restrict results of `/get`, `/list` | [custom Default Data Source](#data-sources) +| Restrict `/save`, `/bulkSave`, `/delete` | • any custom logic: [custom Behaviors](#behaviors)
• restrict targets: [custom Default Data Source](#data-sources)
• static role restrictions: [Class Security Attributes](#class-security-attributes) | +| Restrict targets of instance methods | • [custom Default Data Source](#data-sources)
• specify data source: [LoadFromDataSource](/modeling/model-components/methods.md#loadfromdatasource-type-datasourcetype)
• [custom logic](#custom-methods-and-services) in the method | +| Apply server-side data validation | • implement [validation attributes](#attribute-validation)
• [custom Behaviors](#saves-and-deletes) (for entity CRUD)
• [custom logic](#custom-methods-and-services) (for methods/services) | -[[toc]] ## Endpoint Security @@ -13,7 +30,7 @@ Classes can be hidden from Coalesce entirely by annotating them with `[InternalU `DbSet<>` properties on your `DbContext` class can also be annotated with `[InternalUse]`, causing that type to be treated by Coalesce like an [External Type](/modeling/model-types/external-types.md) rather than an [Entity](/modeling/model-types/entities.md), once again preventing generation of API endpoints but _without_ preventing properties of that type from being exposed. -### Standard CRUD Endpoints +### Class Security Attributes For each of your [Entities](/modeling/model-types/entities.md) and [Custom DTOs](/modeling/model-types/dtos.md), Coalesce generates a set of CRUD API endpoints (`/get`, `/list`, `/count`, `/save`, `/bulkSave`, and `/delete`). The default behavior is that all endpoints require an authenticated user (anonymous users are rejected). @@ -33,15 +50,13 @@ This security is applied to the generated [controllers](https://learn.microsoft. -`/get`, `/list`, `/count`, `/bulkSave` +`/get`, `/list`, `/count` ``` c#:no-line-numbers [ReadAttribute] ``` - -Note: the root model for a bulk save operation requires read permission. All other entities affected by the bulk save operation require their respective attribute for Create/Edit/Delete. @@ -69,6 +84,24 @@ Note: the root model for a bulk save operation requires read permission. All oth ``` + + + +`/bulkSave` + + + +``` c#:no-line-numbers +// Read permission required for the root entity: +[ReadAttribute] + +// Control of each entity affected by the bulk save: +[CreateAttribute] +[EditAttribute] +[DeleteAttribute] +``` + + Here are some examples of applying security attributes to an entity class. If a particular action doesn't need to be restricted, you can omit that attribute, but this example shows usages of all four: @@ -88,7 +121,7 @@ public class Employee } ``` -### Custom Methods and Services +### Method Security Attributes To secure the endpoints generated for your [Custom Methods](/modeling/model-components/methods.md) and [Services](/modeling/model-types/services.md), the [[Execute] attribute](/modeling/model-components/attributes/execute.md) can be used to specify a set of required roles for that endpoint, or to open that endpoint to anonymous users. @@ -115,6 +148,13 @@ public class Employee ## Property/Column Security +Security applied via attributes to properties in Coalesce affects all usages of that property across all Coalesce-generated APIs. This includes usages of that property on types that occur as children of other types, which is a spot where class-level or endpoint-level security generally does not apply. [These attributes](/modeling/model-components/attributes/security-attribute.md) can be placed on the properties on your [Entities](/modeling/model-types/entities.md) and [External Types](/modeling/model-types/external-types.md) to apply role-based restrictions to that property. + +* `ReadAttribute` limits the roles that can read values from that property in responses from the server. +* `EditAttribute` limits the roles that can write values to that property in requests made to the server. +* `RestrictAttribute` registers an implementation of [IPropertyRestriction](#custom-restrictions) that allows for writing custom code to implement these restrictions. + +This security is executed and enforced by the mapping that occurs in the [generated DTOs](/stacks/agnostic/dtos.md), meaning it affects both entity CRUD APIs as well as [Custom Methods](/modeling/model-components/methods.md). It is also checked by the [Standard Data Source](/modeling/model-components/data-sources.md#standard-data-source) to prevent sorting, searching, and filtering by properties that a user is not permitted to read. ### Internal Properties @@ -146,12 +186,6 @@ public class Department } ``` -### Attributes -The [[Read] and [Edit] attributes](/modeling/model-components/attributes/security-attribute.md) can be placed on the properties on your [Entities](/modeling/model-types/entities.md) and [External Types](/modeling/model-types/external-types.md) to apply role-based restrictions to the usage of that property. - -This security is primarily executed and enforced by the mapping that occurs in the [generated DTOs](/stacks/agnostic/dtos.md). It is also checked by the [Standard Data Source](/modeling/model-components/data-sources.md#standard-data-source) to prevent sorting, searching, and filtering by properties that a user is not permitted to read. - - ### Read-Only Properties A property in Coalesce can be made read-only in any of the following ways: @@ -176,13 +210,16 @@ public class Employee // Non-public setter: public DateTime StartDate { get; internal set; } + // No setter: + public string EmploymentDuration => (DateTime.Now - StartDate).ToString(); + // Edits denied: [Edit(SecurityPermissionLevels.DenyAll)] public string EmployeeNumber { get; set; } } ``` -### Read/Write Properties +### Role Restrictions Reading and writing a property in Coalesce can be restricted by roles: @@ -220,6 +257,11 @@ If you have a situation where a property should be editable without knowing the use a custom method on the model to accept and set the new value. +### Custom Restrictions + +@[import-md "after":"# [Restrict]"](../modeling/model-components/attributes/restrict.md) + + ## Row-level Security ### Data Sources @@ -390,8 +432,16 @@ public override async Task TransformResultsAsync( } ``` +### Behaviors + +In Coalesce, [Behaviors](/modeling/model-components/behaviors.md) are the extension point to implement row-level security or other customizations of create/edit/delete operations on your [Entities](/modeling/model-types/entities.md) and [Custom DTOs](/modeling/model-types/dtos.md). Behaviors are implemented on top of data sources, meaning the client request will be rejected if the requested entity for modification cannot be loaded from the entity's default data source. + +By default, each entity will use the [Standard Behaviors](/modeling/model-components/behaviors.md#behaviors), but you can declare a [custom behaviors class](/modeling/model-components/behaviors.md#defining-behaviors) for each of your entities to override this default functionality. + +For most use cases, all your security rules will be implemented in the [BeforeSave/BeforeSaveAsync](/modeling/model-components/behaviors.md#member-beforesaveasync) and [BeforeDelete/BeforeDeleteAsync](/modeling/model-components/behaviors.md#member-beforedeleteasync) methods. + +For a more complete explanation of everything you can do with behaviors, see the full [Behaviors](/modeling/model-components/behaviors.md) documentation page. -For a more complete explanation of everything you can do with data sources, see the full [Data Sources](/modeling/model-components/data-sources.md) documentation page. ### EF Global Query Filters diff --git a/playground/Coalesce.Domain/Case.cs b/playground/Coalesce.Domain/Case.cs index a0b621986..2644df3df 100644 --- a/playground/Coalesce.Domain/Case.cs +++ b/playground/Coalesce.Domain/Case.cs @@ -79,13 +79,15 @@ public enum Statuses [Read] public long AttachmentSize { get; set; } - [Read] + [Read, Restrict] public string AttachmentName { get; set; } + [Restrict] public string AttachmentType { get; set; } [Read, MaxLength(32)] public byte[] AttachmentHash { get; set; } [InternalUse] public CaseAttachmentContent AttachmentContent { get; set; } + public class CaseAttachmentContent { public int CaseKey { get; set; } @@ -232,5 +234,19 @@ public static CaseSummary GetCaseSummary(AppDbContext db) return CaseSummary.GetCaseSummary(db); } + + public class TestRestriction(AppDbContext db) : IPropertyRestriction + { + public bool UserCanRead(IMappingContext context, string propertyName, Case model) + { + // Nonsense arbitrary logic + return db.Cases.Any() && propertyName != null; + } + + public bool UserCanWrite(IMappingContext context, string propertyName, Case? model, object? incomingValue) + { + return false; + } + } } } diff --git a/playground/Coalesce.Web.Ko/Api/Generated/CaseController.g.cs b/playground/Coalesce.Web.Ko/Api/Generated/CaseController.g.cs index d0570be36..89c75cd92 100644 --- a/playground/Coalesce.Web.Ko/Api/Generated/CaseController.g.cs +++ b/playground/Coalesce.Web.Ko/Api/Generated/CaseController.g.cs @@ -119,7 +119,7 @@ public virtual Task> Delete( public virtual ItemResult> GetSomeCases() { IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Case.GetSomeCases( Db ); @@ -386,7 +386,7 @@ public virtual async Task UploadByteArray( public virtual ItemResult GetCaseSummary() { IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Case.GetCaseSummary( Db ); diff --git a/playground/Coalesce.Web.Ko/Api/Generated/CompanyController.g.cs b/playground/Coalesce.Web.Ko/Api/Generated/CompanyController.g.cs index 25ee213af..f61900b83 100644 --- a/playground/Coalesce.Web.Ko/Api/Generated/CompanyController.g.cs +++ b/playground/Coalesce.Web.Ko/Api/Generated/CompanyController.g.cs @@ -113,7 +113,7 @@ public virtual async Task ConflictingParameterNames( if (!_validationResult.WasSuccessful) return _validationResult; } - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); item.ConflictingParameterNames( _params.companyParam.MapToNew(_mappingContext), _params.name @@ -144,7 +144,7 @@ public virtual async Task ConflictingParameterNames( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Company.GetCertainItems( Db, _params.isDeleted diff --git a/playground/Coalesce.Web.Ko/Api/Generated/PersonController.g.cs b/playground/Coalesce.Web.Ko/Api/Generated/PersonController.g.cs index 8bb75b168..10a8b68c8 100644 --- a/playground/Coalesce.Web.Ko/Api/Generated/PersonController.g.cs +++ b/playground/Coalesce.Web.Ko/Api/Generated/PersonController.g.cs @@ -112,7 +112,7 @@ public virtual async Task> Rename( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = item.Rename( _params.name, out includeTree @@ -394,7 +394,7 @@ public virtual async Task> ChangeFirstName( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = item.ChangeFirstName( _params.firstName, _params.title @@ -499,7 +499,7 @@ public virtual ItemResult MethodWithEntityParameter( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Person.MethodWithEntityParameter( Db, _params.person.MapToNew(_mappingContext) @@ -532,7 +532,7 @@ public virtual ListResult SearchPeople( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Person.SearchPeople( Db, _params.criteria.MapToNew(_mappingContext), diff --git a/playground/Coalesce.Web.Ko/Api/Generated/WeatherServiceController.g.cs b/playground/Coalesce.Web.Ko/Api/Generated/WeatherServiceController.g.cs index dc787a52d..f8abde999 100644 --- a/playground/Coalesce.Web.Ko/Api/Generated/WeatherServiceController.g.cs +++ b/playground/Coalesce.Web.Ko/Api/Generated/WeatherServiceController.g.cs @@ -63,7 +63,7 @@ public virtual async Task> GetWeather( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = await Service.GetWeatherAsync( parameterDbContext, _params.location.MapToNew(_mappingContext), diff --git a/playground/Coalesce.Web.Ko/Models/Generated/AuditLogDto.g.cs b/playground/Coalesce.Web.Ko/Models/Generated/AuditLogDto.g.cs index 10cb8f82a..bb88fd442 100644 --- a/playground/Coalesce.Web.Ko/Models/Generated/AuditLogDto.g.cs +++ b/playground/Coalesce.Web.Ko/Models/Generated/AuditLogDto.g.cs @@ -94,9 +94,9 @@ public override void MapFrom(Coalesce.Domain.AuditLog obj, IMappingContext conte if (obj == null) return; var includes = context.Includes; + this.Id = obj.Id; this.Message = obj.Message; this.UserId = obj.UserId; - this.Id = obj.Id; this.Type = obj.Type; this.KeyValue = obj.KeyValue; this.State = obj.State; @@ -130,9 +130,9 @@ public override void MapTo(Coalesce.Domain.AuditLog entity, IMappingContext cont if (OnUpdate(entity, context)) return; + if (ShouldMapTo(nameof(Id))) entity.Id = (Id ?? entity.Id); if (ShouldMapTo(nameof(Message))) entity.Message = Message; if (ShouldMapTo(nameof(UserId))) entity.UserId = UserId; - if (ShouldMapTo(nameof(Id))) entity.Id = (Id ?? entity.Id); if (ShouldMapTo(nameof(Type))) entity.Type = Type; if (ShouldMapTo(nameof(KeyValue))) entity.KeyValue = KeyValue; if (ShouldMapTo(nameof(State))) entity.State = (State ?? entity.State); diff --git a/playground/Coalesce.Web.Ko/Models/Generated/AuditLogPropertyDto.g.cs b/playground/Coalesce.Web.Ko/Models/Generated/AuditLogPropertyDto.g.cs index cf025138a..16151cb46 100644 --- a/playground/Coalesce.Web.Ko/Models/Generated/AuditLogPropertyDto.g.cs +++ b/playground/Coalesce.Web.Ko/Models/Generated/AuditLogPropertyDto.g.cs @@ -16,7 +16,9 @@ public AuditLogPropertyDtoGen() { } private long? _ParentId; private string _PropertyName; private string _OldValue; + private string _OldValueDescription; private string _NewValue; + private string _NewValueDescription; public long? Id { @@ -38,11 +40,21 @@ public string OldValue get => _OldValue; set { _OldValue = value; Changed(nameof(OldValue)); } } + public string OldValueDescription + { + get => _OldValueDescription; + set { _OldValueDescription = value; Changed(nameof(OldValueDescription)); } + } public string NewValue { get => _NewValue; set { _NewValue = value; Changed(nameof(NewValue)); } } + public string NewValueDescription + { + get => _NewValueDescription; + set { _NewValueDescription = value; Changed(nameof(NewValueDescription)); } + } /// /// Map from the domain object to the properties of the current DTO instance. @@ -56,7 +68,9 @@ public override void MapFrom(IntelliTect.Coalesce.AuditLogging.AuditLogProperty this.ParentId = obj.ParentId; this.PropertyName = obj.PropertyName; this.OldValue = obj.OldValue; + this.OldValueDescription = obj.OldValueDescription; this.NewValue = obj.NewValue; + this.NewValueDescription = obj.NewValueDescription; } /// @@ -72,7 +86,9 @@ public override void MapTo(IntelliTect.Coalesce.AuditLogging.AuditLogProperty en if (ShouldMapTo(nameof(ParentId))) entity.ParentId = (ParentId ?? entity.ParentId); if (ShouldMapTo(nameof(PropertyName))) entity.PropertyName = PropertyName; if (ShouldMapTo(nameof(OldValue))) entity.OldValue = OldValue; + if (ShouldMapTo(nameof(OldValueDescription))) entity.OldValueDescription = OldValueDescription; if (ShouldMapTo(nameof(NewValue))) entity.NewValue = NewValue; + if (ShouldMapTo(nameof(NewValueDescription))) entity.NewValueDescription = NewValueDescription; } /// diff --git a/playground/Coalesce.Web.Ko/Models/Generated/CaseDto.g.cs b/playground/Coalesce.Web.Ko/Models/Generated/CaseDto.g.cs index 63192728e..39367cead 100644 --- a/playground/Coalesce.Web.Ko/Models/Generated/CaseDto.g.cs +++ b/playground/Coalesce.Web.Ko/Models/Generated/CaseDto.g.cs @@ -137,8 +137,6 @@ public override void MapFrom(Coalesce.Domain.Case obj, IMappingContext context, this.AssignedToId = obj.AssignedToId; this.ReportedById = obj.ReportedById; this.AttachmentSize = obj.AttachmentSize; - this.AttachmentName = obj.AttachmentName; - this.AttachmentType = obj.AttachmentType; this.AttachmentHash = obj.AttachmentHash; this.Severity = obj.Severity; this.Status = obj.Status; @@ -159,6 +157,8 @@ public override void MapFrom(Coalesce.Domain.Case obj, IMappingContext context, this.DevTeamAssigned = obj.DevTeamAssigned.MapToDto(context, tree?[nameof(this.DevTeamAssigned)]); + if (context.GetPropertyRestriction().UserCanRead(context, nameof(AttachmentName), obj)) this.AttachmentName = obj.AttachmentName; + if (context.GetPropertyRestriction().UserCanRead(context, nameof(AttachmentType), obj)) this.AttachmentType = obj.AttachmentType; if (!(includes == "PersonListGen")) { if (tree == null || tree[nameof(this.AssignedTo)] != null) @@ -168,6 +168,7 @@ public override void MapFrom(Coalesce.Domain.Case obj, IMappingContext context, this.ReportedBy = obj.ReportedBy.MapToDto(context, tree?[nameof(this.ReportedBy)]); } + } /// @@ -185,7 +186,7 @@ public override void MapTo(Coalesce.Domain.Case entity, IMappingContext context) if (ShouldMapTo(nameof(OpenedAt))) entity.OpenedAt = (OpenedAt ?? entity.OpenedAt); if (ShouldMapTo(nameof(AssignedToId))) entity.AssignedToId = AssignedToId; if (ShouldMapTo(nameof(ReportedById))) entity.ReportedById = ReportedById; - if (ShouldMapTo(nameof(AttachmentType))) entity.AttachmentType = AttachmentType; + if (ShouldMapTo(nameof(AttachmentType)) && context.GetPropertyRestriction().UserCanWrite(context, nameof(AttachmentType), entity, AttachmentType)) entity.AttachmentType = AttachmentType; if (ShouldMapTo(nameof(Severity))) entity.Severity = Severity; if (ShouldMapTo(nameof(Status))) entity.Status = (Status ?? entity.Status); if (ShouldMapTo(nameof(DevTeamAssignedId))) entity.DevTeamAssignedId = DevTeamAssignedId; diff --git a/playground/Coalesce.Web.Ko/Models/Generated/ProductDto.g.cs b/playground/Coalesce.Web.Ko/Models/Generated/ProductDto.g.cs index b0e9c7b67..93a154deb 100644 --- a/playground/Coalesce.Web.Ko/Models/Generated/ProductDto.g.cs +++ b/playground/Coalesce.Web.Ko/Models/Generated/ProductDto.g.cs @@ -58,10 +58,7 @@ public override void MapFrom(Coalesce.Domain.Product obj, IMappingContext contex this.Details = obj.Details.MapToDto(context, tree?[nameof(this.Details)]); - if ((context.IsInRoleCached("User"))) - { - this.UniqueId = obj.UniqueId; - } + if ((context.IsInRoleCached("User"))) this.UniqueId = obj.UniqueId; } /// @@ -75,11 +72,8 @@ public override void MapTo(Coalesce.Domain.Product entity, IMappingContext conte if (ShouldMapTo(nameof(ProductId))) entity.ProductId = (ProductId ?? entity.ProductId); if (ShouldMapTo(nameof(Name))) entity.Name = Name; + if (ShouldMapTo(nameof(UniqueId)) && (context.IsInRoleCached("User") && context.IsInRoleCached("Admin"))) entity.UniqueId = (UniqueId ?? entity.UniqueId); if (ShouldMapTo(nameof(Unknown))) entity.Unknown = Unknown; - if ((context.IsInRoleCached("User") && context.IsInRoleCached("Admin"))) - { - if (ShouldMapTo(nameof(UniqueId))) entity.UniqueId = (UniqueId ?? entity.UniqueId); - } } /// diff --git a/playground/Coalesce.Web.Ko/Scripts/Generated/Ko.AuditLogProperty.g.ts b/playground/Coalesce.Web.Ko/Scripts/Generated/Ko.AuditLogProperty.g.ts index f109668a0..ba45847b8 100644 --- a/playground/Coalesce.Web.Ko/Scripts/Generated/Ko.AuditLogProperty.g.ts +++ b/playground/Coalesce.Web.Ko/Scripts/Generated/Ko.AuditLogProperty.g.ts @@ -28,7 +28,9 @@ module ViewModels { public parentId: KnockoutObservable = ko.observable(null); public propertyName: KnockoutObservable = ko.observable(null); public oldValue: KnockoutObservable = ko.observable(null); + public oldValueDescription: KnockoutObservable = ko.observable(null); public newValue: KnockoutObservable = ko.observable(null); + public newValueDescription: KnockoutObservable = ko.observable(null); @@ -56,7 +58,9 @@ module ViewModels { this.parentId(data.parentId); this.propertyName(data.propertyName); this.oldValue(data.oldValue); + this.oldValueDescription(data.oldValueDescription); this.newValue(data.newValue); + this.newValueDescription(data.newValueDescription); if (this.coalesceConfig.onLoadFromDto()){ this.coalesceConfig.onLoadFromDto()(this as any); } @@ -73,7 +77,9 @@ module ViewModels { dto.parentId = this.parentId(); dto.propertyName = this.propertyName(); dto.oldValue = this.oldValue(); + dto.oldValueDescription = this.oldValueDescription(); dto.newValue = this.newValue(); + dto.newValueDescription = this.newValueDescription(); return dto; } @@ -110,7 +116,9 @@ module ViewModels { self.parentId.subscribe(self.autoSave); self.propertyName.subscribe(self.autoSave); self.oldValue.subscribe(self.autoSave); + self.oldValueDescription.subscribe(self.autoSave); self.newValue.subscribe(self.autoSave); + self.newValueDescription.subscribe(self.autoSave); if (newItem) { self.loadFromDto(newItem, true); diff --git a/playground/Coalesce.Web.Ko/Scripts/Generated/Ko.AuditLogPropertyList.g.ts b/playground/Coalesce.Web.Ko/Scripts/Generated/Ko.AuditLogPropertyList.g.ts index 736553196..bd58e6ef7 100644 --- a/playground/Coalesce.Web.Ko/Scripts/Generated/Ko.AuditLogPropertyList.g.ts +++ b/playground/Coalesce.Web.Ko/Scripts/Generated/Ko.AuditLogPropertyList.g.ts @@ -20,7 +20,9 @@ module ListViewModels { parentId?: string; propertyName?: string; oldValue?: string; + oldValueDescription?: string; newValue?: string; + newValueDescription?: string; } | null = null; /** The namespace containing all possible values of this.dataSource. */ diff --git a/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/Cards.cshtml b/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/Cards.cshtml index 99add533d..57ba10add 100644 --- a/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/Cards.cshtml +++ b/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/Cards.cshtml @@ -70,10 +70,18 @@
@(Knockout.DisplayFor(p => p.OldValue, false))
+
Old Value Description
+
+ @(Knockout.DisplayFor(p => p.OldValueDescription, false)) +
New Value
@(Knockout.DisplayFor(p => p.NewValue, false))
+
New Value Description
+
+ @(Knockout.DisplayFor(p => p.NewValueDescription, false)) +
diff --git a/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/CreateEdit.cshtml b/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/CreateEdit.cshtml index 572fbef26..6060ca27a 100644 --- a/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/CreateEdit.cshtml +++ b/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/CreateEdit.cshtml @@ -38,10 +38,18 @@
@(Knockout.InputFor(p => p.OldValue))
+
+ +
@(Knockout.InputFor(p => p.OldValueDescription))
+
@(Knockout.InputFor(p => p.NewValue))
+
+ +
@(Knockout.InputFor(p => p.NewValueDescription))
+
diff --git a/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/Table.cshtml b/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/Table.cshtml index 581275bbe..95c3d887d 100644 --- a/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/Table.cshtml +++ b/playground/Coalesce.Web.Ko/Views/Generated/AuditLogProperty/Table.cshtml @@ -80,10 +80,18 @@ Old Value + + Old Value Description + + New Value + + New Value Description + + @@ -95,14 +103,18 @@ @(Knockout.InputFor(p => p.ParentId)) @(Knockout.InputFor(p => p.PropertyName)) @(Knockout.InputFor(p => p.OldValue)) + @(Knockout.InputFor(p => p.OldValueDescription)) @(Knockout.InputFor(p => p.NewValue)) + @(Knockout.InputFor(p => p.NewValueDescription)) } else { @(Knockout.DisplayFor(p => p.ParentId, true)) @(Knockout.DisplayFor(p => p.PropertyName, true)) @(Knockout.DisplayFor(p => p.OldValue, true)) + @(Knockout.DisplayFor(p => p.OldValueDescription, true)) @(Knockout.DisplayFor(p => p.NewValue, true)) + @(Knockout.DisplayFor(p => p.NewValueDescription, true)) } diff --git a/playground/Coalesce.Web.Vue2/Api/Generated/CaseController.g.cs b/playground/Coalesce.Web.Vue2/Api/Generated/CaseController.g.cs index ca3385209..b28d6beb6 100644 --- a/playground/Coalesce.Web.Vue2/Api/Generated/CaseController.g.cs +++ b/playground/Coalesce.Web.Vue2/Api/Generated/CaseController.g.cs @@ -119,7 +119,7 @@ public virtual Task> Delete( public virtual ItemResult> GetSomeCases() { IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Case.GetSomeCases( Db ); @@ -386,7 +386,7 @@ public virtual async Task UploadByteArray( public virtual ItemResult GetCaseSummary() { IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Case.GetCaseSummary( Db ); diff --git a/playground/Coalesce.Web.Vue2/Api/Generated/CompanyController.g.cs b/playground/Coalesce.Web.Vue2/Api/Generated/CompanyController.g.cs index 6a13a058f..fef8a5bdb 100644 --- a/playground/Coalesce.Web.Vue2/Api/Generated/CompanyController.g.cs +++ b/playground/Coalesce.Web.Vue2/Api/Generated/CompanyController.g.cs @@ -113,7 +113,7 @@ public virtual async Task ConflictingParameterNames( if (!_validationResult.WasSuccessful) return _validationResult; } - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); item.ConflictingParameterNames( _params.companyParam.MapToNew(_mappingContext), _params.name @@ -144,7 +144,7 @@ public virtual async Task ConflictingParameterNames( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Company.GetCertainItems( Db, _params.isDeleted diff --git a/playground/Coalesce.Web.Vue2/Api/Generated/PersonController.g.cs b/playground/Coalesce.Web.Vue2/Api/Generated/PersonController.g.cs index 9c77239cc..3251dcd09 100644 --- a/playground/Coalesce.Web.Vue2/Api/Generated/PersonController.g.cs +++ b/playground/Coalesce.Web.Vue2/Api/Generated/PersonController.g.cs @@ -112,7 +112,7 @@ public virtual async Task> Rename( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = item.Rename( _params.name, out includeTree @@ -394,7 +394,7 @@ public virtual async Task> ChangeFirstName( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = item.ChangeFirstName( _params.firstName, _params.title @@ -499,7 +499,7 @@ public virtual ItemResult MethodWithEntityParameter( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Person.MethodWithEntityParameter( Db, _params.person.MapToNew(_mappingContext) @@ -532,7 +532,7 @@ public virtual ListResult SearchPeople( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Person.SearchPeople( Db, _params.criteria.MapToNew(_mappingContext), diff --git a/playground/Coalesce.Web.Vue2/Api/Generated/WeatherServiceController.g.cs b/playground/Coalesce.Web.Vue2/Api/Generated/WeatherServiceController.g.cs index 752305827..35757bb7d 100644 --- a/playground/Coalesce.Web.Vue2/Api/Generated/WeatherServiceController.g.cs +++ b/playground/Coalesce.Web.Vue2/Api/Generated/WeatherServiceController.g.cs @@ -63,7 +63,7 @@ public virtual async Task> GetWeather( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = await Service.GetWeatherAsync( parameterDbContext, _params.location.MapToNew(_mappingContext), diff --git a/playground/Coalesce.Web.Vue2/Models/Generated/AuditLogDto.g.cs b/playground/Coalesce.Web.Vue2/Models/Generated/AuditLogDto.g.cs index 51a1bd964..9fbf3b5bd 100644 --- a/playground/Coalesce.Web.Vue2/Models/Generated/AuditLogDto.g.cs +++ b/playground/Coalesce.Web.Vue2/Models/Generated/AuditLogDto.g.cs @@ -94,9 +94,9 @@ public override void MapFrom(Coalesce.Domain.AuditLog obj, IMappingContext conte if (obj == null) return; var includes = context.Includes; + this.Id = obj.Id; this.Message = obj.Message; this.UserId = obj.UserId; - this.Id = obj.Id; this.Type = obj.Type; this.KeyValue = obj.KeyValue; this.State = obj.State; @@ -130,9 +130,9 @@ public override void MapTo(Coalesce.Domain.AuditLog entity, IMappingContext cont if (OnUpdate(entity, context)) return; + if (ShouldMapTo(nameof(Id))) entity.Id = (Id ?? entity.Id); if (ShouldMapTo(nameof(Message))) entity.Message = Message; if (ShouldMapTo(nameof(UserId))) entity.UserId = UserId; - if (ShouldMapTo(nameof(Id))) entity.Id = (Id ?? entity.Id); if (ShouldMapTo(nameof(Type))) entity.Type = Type; if (ShouldMapTo(nameof(KeyValue))) entity.KeyValue = KeyValue; if (ShouldMapTo(nameof(State))) entity.State = (State ?? entity.State); diff --git a/playground/Coalesce.Web.Vue2/Models/Generated/AuditLogPropertyDto.g.cs b/playground/Coalesce.Web.Vue2/Models/Generated/AuditLogPropertyDto.g.cs index 965a3325f..13bcdfb09 100644 --- a/playground/Coalesce.Web.Vue2/Models/Generated/AuditLogPropertyDto.g.cs +++ b/playground/Coalesce.Web.Vue2/Models/Generated/AuditLogPropertyDto.g.cs @@ -16,7 +16,9 @@ public AuditLogPropertyDtoGen() { } private long? _ParentId; private string _PropertyName; private string _OldValue; + private string _OldValueDescription; private string _NewValue; + private string _NewValueDescription; public long? Id { @@ -38,11 +40,21 @@ public string OldValue get => _OldValue; set { _OldValue = value; Changed(nameof(OldValue)); } } + public string OldValueDescription + { + get => _OldValueDescription; + set { _OldValueDescription = value; Changed(nameof(OldValueDescription)); } + } public string NewValue { get => _NewValue; set { _NewValue = value; Changed(nameof(NewValue)); } } + public string NewValueDescription + { + get => _NewValueDescription; + set { _NewValueDescription = value; Changed(nameof(NewValueDescription)); } + } /// /// Map from the domain object to the properties of the current DTO instance. @@ -56,7 +68,9 @@ public override void MapFrom(IntelliTect.Coalesce.AuditLogging.AuditLogProperty this.ParentId = obj.ParentId; this.PropertyName = obj.PropertyName; this.OldValue = obj.OldValue; + this.OldValueDescription = obj.OldValueDescription; this.NewValue = obj.NewValue; + this.NewValueDescription = obj.NewValueDescription; } /// @@ -72,7 +86,9 @@ public override void MapTo(IntelliTect.Coalesce.AuditLogging.AuditLogProperty en if (ShouldMapTo(nameof(ParentId))) entity.ParentId = (ParentId ?? entity.ParentId); if (ShouldMapTo(nameof(PropertyName))) entity.PropertyName = PropertyName; if (ShouldMapTo(nameof(OldValue))) entity.OldValue = OldValue; + if (ShouldMapTo(nameof(OldValueDescription))) entity.OldValueDescription = OldValueDescription; if (ShouldMapTo(nameof(NewValue))) entity.NewValue = NewValue; + if (ShouldMapTo(nameof(NewValueDescription))) entity.NewValueDescription = NewValueDescription; } /// diff --git a/playground/Coalesce.Web.Vue2/Models/Generated/CaseDto.g.cs b/playground/Coalesce.Web.Vue2/Models/Generated/CaseDto.g.cs index 7fca7cfca..cc4cb5727 100644 --- a/playground/Coalesce.Web.Vue2/Models/Generated/CaseDto.g.cs +++ b/playground/Coalesce.Web.Vue2/Models/Generated/CaseDto.g.cs @@ -137,8 +137,6 @@ public override void MapFrom(Coalesce.Domain.Case obj, IMappingContext context, this.AssignedToId = obj.AssignedToId; this.ReportedById = obj.ReportedById; this.AttachmentSize = obj.AttachmentSize; - this.AttachmentName = obj.AttachmentName; - this.AttachmentType = obj.AttachmentType; this.AttachmentHash = obj.AttachmentHash; this.Severity = obj.Severity; this.Status = obj.Status; @@ -159,6 +157,8 @@ public override void MapFrom(Coalesce.Domain.Case obj, IMappingContext context, this.DevTeamAssigned = obj.DevTeamAssigned.MapToDto(context, tree?[nameof(this.DevTeamAssigned)]); + if (context.GetPropertyRestriction().UserCanRead(context, nameof(AttachmentName), obj)) this.AttachmentName = obj.AttachmentName; + if (context.GetPropertyRestriction().UserCanRead(context, nameof(AttachmentType), obj)) this.AttachmentType = obj.AttachmentType; if (!(includes == "PersonListGen")) { if (tree == null || tree[nameof(this.AssignedTo)] != null) @@ -168,6 +168,7 @@ public override void MapFrom(Coalesce.Domain.Case obj, IMappingContext context, this.ReportedBy = obj.ReportedBy.MapToDto(context, tree?[nameof(this.ReportedBy)]); } + } /// @@ -185,7 +186,7 @@ public override void MapTo(Coalesce.Domain.Case entity, IMappingContext context) if (ShouldMapTo(nameof(OpenedAt))) entity.OpenedAt = (OpenedAt ?? entity.OpenedAt); if (ShouldMapTo(nameof(AssignedToId))) entity.AssignedToId = AssignedToId; if (ShouldMapTo(nameof(ReportedById))) entity.ReportedById = ReportedById; - if (ShouldMapTo(nameof(AttachmentType))) entity.AttachmentType = AttachmentType; + if (ShouldMapTo(nameof(AttachmentType)) && context.GetPropertyRestriction().UserCanWrite(context, nameof(AttachmentType), entity, AttachmentType)) entity.AttachmentType = AttachmentType; if (ShouldMapTo(nameof(Severity))) entity.Severity = Severity; if (ShouldMapTo(nameof(Status))) entity.Status = (Status ?? entity.Status); if (ShouldMapTo(nameof(DevTeamAssignedId))) entity.DevTeamAssignedId = DevTeamAssignedId; diff --git a/playground/Coalesce.Web.Vue2/Models/Generated/ProductDto.g.cs b/playground/Coalesce.Web.Vue2/Models/Generated/ProductDto.g.cs index 1f6e5b911..f7d795d2e 100644 --- a/playground/Coalesce.Web.Vue2/Models/Generated/ProductDto.g.cs +++ b/playground/Coalesce.Web.Vue2/Models/Generated/ProductDto.g.cs @@ -58,10 +58,7 @@ public override void MapFrom(Coalesce.Domain.Product obj, IMappingContext contex this.Details = obj.Details.MapToDto(context, tree?[nameof(this.Details)]); - if ((context.IsInRoleCached("User"))) - { - this.UniqueId = obj.UniqueId; - } + if ((context.IsInRoleCached("User"))) this.UniqueId = obj.UniqueId; } /// @@ -75,11 +72,8 @@ public override void MapTo(Coalesce.Domain.Product entity, IMappingContext conte if (ShouldMapTo(nameof(ProductId))) entity.ProductId = (ProductId ?? entity.ProductId); if (ShouldMapTo(nameof(Name))) entity.Name = Name; + if (ShouldMapTo(nameof(UniqueId)) && (context.IsInRoleCached("User") && context.IsInRoleCached("Admin"))) entity.UniqueId = (UniqueId ?? entity.UniqueId); if (ShouldMapTo(nameof(Unknown))) entity.Unknown = Unknown; - if ((context.IsInRoleCached("User") && context.IsInRoleCached("Admin"))) - { - if (ShouldMapTo(nameof(UniqueId))) entity.UniqueId = (UniqueId ?? entity.UniqueId); - } } /// diff --git a/playground/Coalesce.Web.Vue2/src/metadata.g.ts b/playground/Coalesce.Web.Vue2/src/metadata.g.ts index 1288f792d..e4bfe0244 100644 --- a/playground/Coalesce.Web.Vue2/src/metadata.g.ts +++ b/playground/Coalesce.Web.Vue2/src/metadata.g.ts @@ -287,12 +287,24 @@ export const AuditLogProperty = domain.types.AuditLogProperty = { type: "string", role: "value", }, + oldValueDescription: { + name: "oldValueDescription", + displayName: "Old Value Description", + type: "string", + role: "value", + }, newValue: { name: "newValue", displayName: "New Value", type: "string", role: "value", }, + newValueDescription: { + name: "newValueDescription", + displayName: "New Value Description", + type: "string", + role: "value", + }, }, methods: { }, diff --git a/playground/Coalesce.Web.Vue2/src/models.g.ts b/playground/Coalesce.Web.Vue2/src/models.g.ts index 0daefde2e..1c97860e7 100644 --- a/playground/Coalesce.Web.Vue2/src/models.g.ts +++ b/playground/Coalesce.Web.Vue2/src/models.g.ts @@ -81,7 +81,9 @@ export interface AuditLogProperty extends Model implements $models.AuditLogProperty { diff --git a/playground/Coalesce.Web.Vue3/Api/Generated/CaseController.g.cs b/playground/Coalesce.Web.Vue3/Api/Generated/CaseController.g.cs index 64ade4b99..e3c7c90a4 100644 --- a/playground/Coalesce.Web.Vue3/Api/Generated/CaseController.g.cs +++ b/playground/Coalesce.Web.Vue3/Api/Generated/CaseController.g.cs @@ -119,7 +119,7 @@ public virtual Task> Delete( public virtual ItemResult> GetSomeCases() { IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Case.GetSomeCases( Db ); @@ -386,7 +386,7 @@ public virtual async Task UploadByteArray( public virtual ItemResult GetCaseSummary() { IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Case.GetCaseSummary( Db ); diff --git a/playground/Coalesce.Web.Vue3/Api/Generated/CompanyController.g.cs b/playground/Coalesce.Web.Vue3/Api/Generated/CompanyController.g.cs index 9c6e2a78e..f4df70f89 100644 --- a/playground/Coalesce.Web.Vue3/Api/Generated/CompanyController.g.cs +++ b/playground/Coalesce.Web.Vue3/Api/Generated/CompanyController.g.cs @@ -113,7 +113,7 @@ public virtual async Task ConflictingParameterNames( if (!_validationResult.WasSuccessful) return _validationResult; } - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); item.ConflictingParameterNames( _params.companyParam.MapToNew(_mappingContext), _params.name @@ -144,7 +144,7 @@ public virtual async Task ConflictingParameterNames( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Company.GetCertainItems( Db, _params.isDeleted diff --git a/playground/Coalesce.Web.Vue3/Api/Generated/PersonController.g.cs b/playground/Coalesce.Web.Vue3/Api/Generated/PersonController.g.cs index 22be7b487..798aa7925 100644 --- a/playground/Coalesce.Web.Vue3/Api/Generated/PersonController.g.cs +++ b/playground/Coalesce.Web.Vue3/Api/Generated/PersonController.g.cs @@ -112,7 +112,7 @@ public virtual async Task> Rename( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = item.Rename( _params.name, out includeTree @@ -394,7 +394,7 @@ public virtual async Task> ChangeFirstName( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = item.ChangeFirstName( _params.firstName, _params.title @@ -499,7 +499,7 @@ public virtual ItemResult MethodWithEntityParameter( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Person.MethodWithEntityParameter( Db, _params.person.MapToNew(_mappingContext) @@ -532,7 +532,7 @@ public virtual ListResult SearchPeople( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = Coalesce.Domain.Person.SearchPeople( Db, _params.criteria.MapToNew(_mappingContext), diff --git a/playground/Coalesce.Web.Vue3/Api/Generated/WeatherServiceController.g.cs b/playground/Coalesce.Web.Vue3/Api/Generated/WeatherServiceController.g.cs index ae1e65693..3dcdf22f6 100644 --- a/playground/Coalesce.Web.Vue3/Api/Generated/WeatherServiceController.g.cs +++ b/playground/Coalesce.Web.Vue3/Api/Generated/WeatherServiceController.g.cs @@ -63,7 +63,7 @@ public virtual async Task> GetWeather( } IncludeTree includeTree = null; - var _mappingContext = new MappingContext(User); + var _mappingContext = new MappingContext(Context); var _methodResult = await Service.GetWeatherAsync( parameterDbContext, _params.location.MapToNew(_mappingContext), diff --git a/playground/Coalesce.Web.Vue3/Models/Generated/AuditLogDto.g.cs b/playground/Coalesce.Web.Vue3/Models/Generated/AuditLogDto.g.cs index 1f462f70a..ec8c8d7bb 100644 --- a/playground/Coalesce.Web.Vue3/Models/Generated/AuditLogDto.g.cs +++ b/playground/Coalesce.Web.Vue3/Models/Generated/AuditLogDto.g.cs @@ -94,9 +94,9 @@ public override void MapFrom(Coalesce.Domain.AuditLog obj, IMappingContext conte if (obj == null) return; var includes = context.Includes; + this.Id = obj.Id; this.Message = obj.Message; this.UserId = obj.UserId; - this.Id = obj.Id; this.Type = obj.Type; this.KeyValue = obj.KeyValue; this.State = obj.State; @@ -130,9 +130,9 @@ public override void MapTo(Coalesce.Domain.AuditLog entity, IMappingContext cont if (OnUpdate(entity, context)) return; + if (ShouldMapTo(nameof(Id))) entity.Id = (Id ?? entity.Id); if (ShouldMapTo(nameof(Message))) entity.Message = Message; if (ShouldMapTo(nameof(UserId))) entity.UserId = UserId; - if (ShouldMapTo(nameof(Id))) entity.Id = (Id ?? entity.Id); if (ShouldMapTo(nameof(Type))) entity.Type = Type; if (ShouldMapTo(nameof(KeyValue))) entity.KeyValue = KeyValue; if (ShouldMapTo(nameof(State))) entity.State = (State ?? entity.State); diff --git a/playground/Coalesce.Web.Vue3/Models/Generated/CaseDto.g.cs b/playground/Coalesce.Web.Vue3/Models/Generated/CaseDto.g.cs index 1c04da7e4..cc57beb08 100644 --- a/playground/Coalesce.Web.Vue3/Models/Generated/CaseDto.g.cs +++ b/playground/Coalesce.Web.Vue3/Models/Generated/CaseDto.g.cs @@ -137,8 +137,6 @@ public override void MapFrom(Coalesce.Domain.Case obj, IMappingContext context, this.AssignedToId = obj.AssignedToId; this.ReportedById = obj.ReportedById; this.AttachmentSize = obj.AttachmentSize; - this.AttachmentName = obj.AttachmentName; - this.AttachmentType = obj.AttachmentType; this.AttachmentHash = obj.AttachmentHash; this.Severity = obj.Severity; this.Status = obj.Status; @@ -159,6 +157,8 @@ public override void MapFrom(Coalesce.Domain.Case obj, IMappingContext context, this.DevTeamAssigned = obj.DevTeamAssigned.MapToDto(context, tree?[nameof(this.DevTeamAssigned)]); + if (context.GetPropertyRestriction().UserCanRead(context, nameof(AttachmentName), obj)) this.AttachmentName = obj.AttachmentName; + if (context.GetPropertyRestriction().UserCanRead(context, nameof(AttachmentType), obj)) this.AttachmentType = obj.AttachmentType; if (!(includes == "PersonListGen")) { if (tree == null || tree[nameof(this.AssignedTo)] != null) @@ -168,6 +168,7 @@ public override void MapFrom(Coalesce.Domain.Case obj, IMappingContext context, this.ReportedBy = obj.ReportedBy.MapToDto(context, tree?[nameof(this.ReportedBy)]); } + } /// @@ -185,7 +186,7 @@ public override void MapTo(Coalesce.Domain.Case entity, IMappingContext context) if (ShouldMapTo(nameof(OpenedAt))) entity.OpenedAt = (OpenedAt ?? entity.OpenedAt); if (ShouldMapTo(nameof(AssignedToId))) entity.AssignedToId = AssignedToId; if (ShouldMapTo(nameof(ReportedById))) entity.ReportedById = ReportedById; - if (ShouldMapTo(nameof(AttachmentType))) entity.AttachmentType = AttachmentType; + if (ShouldMapTo(nameof(AttachmentType)) && context.GetPropertyRestriction().UserCanWrite(context, nameof(AttachmentType), entity, AttachmentType)) entity.AttachmentType = AttachmentType; if (ShouldMapTo(nameof(Severity))) entity.Severity = Severity; if (ShouldMapTo(nameof(Status))) entity.Status = (Status ?? entity.Status); if (ShouldMapTo(nameof(DevTeamAssignedId))) entity.DevTeamAssignedId = DevTeamAssignedId; diff --git a/playground/Coalesce.Web.Vue3/Models/Generated/ProductDto.g.cs b/playground/Coalesce.Web.Vue3/Models/Generated/ProductDto.g.cs index 5a677bc7e..8b007b62c 100644 --- a/playground/Coalesce.Web.Vue3/Models/Generated/ProductDto.g.cs +++ b/playground/Coalesce.Web.Vue3/Models/Generated/ProductDto.g.cs @@ -58,10 +58,7 @@ public override void MapFrom(Coalesce.Domain.Product obj, IMappingContext contex this.Details = obj.Details.MapToDto(context, tree?[nameof(this.Details)]); - if ((context.IsInRoleCached("User"))) - { - this.UniqueId = obj.UniqueId; - } + if ((context.IsInRoleCached("User"))) this.UniqueId = obj.UniqueId; } /// @@ -75,11 +72,8 @@ public override void MapTo(Coalesce.Domain.Product entity, IMappingContext conte if (ShouldMapTo(nameof(ProductId))) entity.ProductId = (ProductId ?? entity.ProductId); if (ShouldMapTo(nameof(Name))) entity.Name = Name; + if (ShouldMapTo(nameof(UniqueId)) && (context.IsInRoleCached("User") && context.IsInRoleCached("Admin"))) entity.UniqueId = (UniqueId ?? entity.UniqueId); if (ShouldMapTo(nameof(Unknown))) entity.Unknown = Unknown; - if ((context.IsInRoleCached("User") && context.IsInRoleCached("Admin"))) - { - if (ShouldMapTo(nameof(UniqueId))) entity.UniqueId = (UniqueId ?? entity.UniqueId); - } } /// diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs index 65e927b5a..d0f1493fb 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs @@ -55,6 +55,7 @@ public override void BuildOutput(CSharpCodeBuilder b) private void WriteDtoClass(CSharpCodeBuilder b) { + using (b.Block($"public partial class {Model.DtoName} : GeneratedDto<{Model.FullyQualifiedName}>")) { b.Line($"public {Model.DtoName}() {{ }}"); @@ -75,30 +76,56 @@ private void WriteDtoClass(CSharpCodeBuilder b) } } - void WriteSetters(IEnumerable<(string conditional, string setter)> settersAndConditionals) + void WriteSetters(IEnumerable<(IEnumerable conditionals, string setter)> settersAndConditionals) { foreach (var conditionGroup in settersAndConditionals - .GroupBy(s => s.conditional, s => s.setter)) + .GroupBy(s => s.conditionals.FirstOrDefault(), s => s)) { - if (!string.IsNullOrWhiteSpace(conditionGroup.Key)) + if (conditionGroup.Key is null) { - b.Line($"if ({conditionGroup.Key}) {{"); foreach (var setter in conditionGroup) { - b.Indented(setter); + b.Line(setter.setter); } - b.Line("}"); } - else + else if ( + // There are multiple setters that use `conditionGroup.Key` as at least part of + // their conditional. Group them together in a single `if` that consumes + // that shared part of the mapping conditional. + conditionGroup.Count() > 1 || + // Also use a full block if the setter contains multiple statements + // that would make a braceless block impossible. + (conditionGroup.Single() is var singleSetter && singleSetter.setter.Count(x => x == ';') > 1) + ) { - foreach (var setter in conditionGroup) + using (b.Block($"if ({conditionGroup.Key})")) { - b.Line(setter); + WriteSetters(conditionGroup.Select(g => (g.conditionals.Skip(1), g.setter))); } + b.Line(); + } + else + { + // There's only one setter in `conditionGroup`, + // such that we can merge all its conditions together. + b.Append("if ("); + b.Append(string.Join(" && ", singleSetter.conditionals)); + b.Append(") "); + b.Line(singleSetter.setter); } } } + var orderedProps = Model + .ClientProperties + // PK always first so it is available to guide decisions in IPropertyRestrictions + .OrderBy(p => !p.IsPrimaryKey) + // Scalars before objects + .ThenBy(p => p.PureType.HasClassViewModel) + // Finally, preserve original field order. + .ThenBy(p => p.ClassFieldOrder) + .ToList(); + b.DocComment("Map from the domain object to the properties of the current DTO instance."); using (b.Block($"public override void MapFrom({Model.FullyQualifiedName} obj, IMappingContext context, IncludeTree tree = null)")) { @@ -106,10 +133,8 @@ void WriteSetters(IEnumerable<(string conditional, string setter)> settersAndCon b.Line("var includes = context.Includes;"); b.Line(); - WriteSetters(Model - .ClientProperties + WriteSetters(orderedProps .Where(p => p.SecurityInfo.Read.IsAllowed()) - .OrderBy(p => p.PureType.HasClassViewModel) .Select(ModelToDtoPropertySetter)); } @@ -121,21 +146,20 @@ void WriteSetters(IEnumerable<(string conditional, string setter)> settersAndCon b.Line("if (OnUpdate(entity, context)) return;"); b.Line(); - WriteSetters(Model - .ClientProperties + WriteSetters(orderedProps .Where(p => p.SecurityInfo.Edit.IsAllowed()) .Select(p => { var (conditional, setter) = DtoToModelPropertySetter(p, p.SecurityInfo.Edit); - return (conditional, $"if (ShouldMapTo(nameof({p.Name}))) entity.{p.Name} = {setter};"); + conditional = conditional.Prepend($"ShouldMapTo(nameof({p.Name}))"); + return (conditional, $"entity.{p.Name} = {setter};"); })); } b.DocComment("Map from the current DTO instance to a new instance of the domain object."); using (b.Block($"public override {Model.FullyQualifiedName} MapToNew(IMappingContext context)")) { - var properties = Model - .ClientProperties + var properties = orderedProps .Where(p => p.SecurityInfo.Init.IsAllowed()) .ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); @@ -227,7 +251,8 @@ void WriteSetters(IEnumerable<(string conditional, string setter)> settersAndCon .Select(p => { var (conditional, setter) = DtoToModelPropertySetter(p, p.SecurityInfo.Init); - return (conditional, $"if (ShouldMapTo(nameof({p.Name}))) entity.{p.Name} = {setter};"); + conditional = conditional.Prepend($"ShouldMapTo(nameof({p.Name}))"); + return (conditional, $"entity.{p.Name} = {setter};"); })); b.Line(); @@ -237,11 +262,11 @@ string InlinePropertyRhs(PropertyViewModel p) { // Init-only props must be set here where we instantiate the type. // They cannot be handled by the record - var (conditional, setter) = DtoToModelPropertySetter(p, p.SecurityInfo.Init, fallbackPrefix: null); + var (conditional, setter) = DtoToModelPropertySetter(p, p.SecurityInfo.Init, modelVar: null); - if (!string.IsNullOrWhiteSpace(conditional)) + if (conditional.Any()) { - return $"({conditional}) ? {setter} : default"; + return $"({string.Join(" && ", conditional)}) ? {setter} : default"; } else { @@ -258,13 +283,17 @@ string InlinePropertyRhs(PropertyViewModel p) /// /// The property whose permissions will be evaluated. /// The permission info to pull the required roles from. + /// The variable that holds the entity/model instance. /// - private string GetPropertySetterConditional(PropertyViewModel property, PropertySecurityPermission permission) + private IEnumerable GetPropertySetterConditional( + PropertyViewModel property, + PropertySecurityPermission permission, + string modelVar) { string RoleCheck(string role) => $"context.IsInRoleCached(\"{role.EscapeStringLiteralForCSharp()}\")"; string IncludesCheck(string include) => $"includes == \"{include.EscapeStringLiteralForCSharp()}\""; - string roles = string.Join(" && ", permission.RoleLists.Select(rl => string.Join(" || ", rl.Select(RoleCheck)))); + string roles = string.Join(" && ", permission.RoleLists.Select(rl => string.Join(" || ", rl.Select(RoleCheck))).Distinct()); var includes = string.Join(" || ", property.DtoIncludes.Select(IncludesCheck)); var excludes = string.Join(" || ", property.DtoExcludes.Select(IncludesCheck)); @@ -274,16 +303,29 @@ private string GetPropertySetterConditional(PropertyViewModel property, Property if (!string.IsNullOrEmpty(includes)) statement.Add($"({includes})"); if (!string.IsNullOrEmpty(excludes)) statement.Add($"!({excludes})"); - return string.Join(" && ", statement); + foreach (var restriction in property.SecurityInfo.Restrictions) + { + var restrictionExpr = $"context.GetPropertyRestriction<{restriction.FullyQualifiedName}>()"; + if (permission.Name == "Read") + { + statement.Add($"{restrictionExpr}.UserCanRead(context, nameof({property.Name}), {modelVar ?? "null"})"); + } + else + { + statement.Add($"{restrictionExpr}.UserCanWrite(context, nameof({property.Name}), {modelVar ?? "null"}, {property.Name})"); + } + } + + return statement; } /// /// Get the conditional and a C# expression that will map the property from a DTO to a local model object. /// - private (string conditional, string setter) DtoToModelPropertySetter( + private (IEnumerable conditionals, string setter) DtoToModelPropertySetter( PropertyViewModel property, PropertySecurityPermission permission, - string fallbackPrefix = "entity." + string modelVar = "entity" ) { string name = property.Name; @@ -304,9 +346,9 @@ private string GetPropertySetterConditional(PropertyViewModel property, Property { setter = $"{name}?.Select(f => f.MapToNew(context)).{(property.Type.IsArray ? "ToArray" : "ToList")}()"; } - else if (fallbackPrefix != null) + else if (modelVar != null) { - setter = $"{name}?.MapToModelOrNew({fallbackPrefix}{name}, context)"; + setter = $"{name}?.MapToModelOrNew({modelVar}.{name}, context)"; } else { @@ -323,36 +365,29 @@ private string GetPropertySetterConditional(PropertyViewModel property, Property var newValue = name; if (!property.Type.IsReferenceOrNullableValue && property.Type.CsDefaultValue != "null") { - var fallback = fallbackPrefix == null ? "default" : $"{fallbackPrefix}{name}"; + var fallback = modelVar == null ? "default" : $"{modelVar}.{name}"; newValue = $"({newValue} ?? {fallback})"; } setter = $"{newValue}"; } - var statement = GetPropertySetterConditional(property, permission); - if (!string.IsNullOrWhiteSpace(statement)) - { - return (statement, setter); - } - else - { - return (null, setter); - } + var statement = GetPropertySetterConditional(property, permission, modelVar); + return (statement, setter); } /// /// Get the conditional and a C# expression that will map the property from a local object to a DTO. /// /// The property to map - private (string conditional, string setter) ModelToDtoPropertySetter(PropertyViewModel property) + private (IEnumerable conditionals, string setter) ModelToDtoPropertySetter(PropertyViewModel property) { string name = property.Name; - string objectName = "this"; + string dtoVar = "this"; string setter; string mapCall() => property.Object.IsDto ? "" // If we hang an IClassDto off an external type, or another IClassDto, no mapping needed - it is already the desired type. - : $".MapToDto<{property.Object.FullyQualifiedName}, {property.Object.DtoName}>(context, tree?[nameof({objectName}.{name})])"; + : $".MapToDto<{property.Object.FullyQualifiedName}, {property.Object.DtoName}>(context, tree?[nameof({dtoVar}.{name})])"; if (property.Type.IsCollection) { @@ -367,12 +402,12 @@ string mapCall() => property.Object.IsDto sb.Append($"if (propVal{name} != null"); if (property.Object.HasDbSet) { - sb.Append($" && (tree == null || tree[nameof({objectName}.{name})] != null)"); + sb.Append($" && (tree == null || tree[nameof({dtoVar}.{name})] != null)"); } sb.Line(") {"); using (sb.Indented()) { - sb.Line($"{objectName}.{name} = propVal{name}"); + sb.Line($"{dtoVar}.{name} = propVal{name}"); var defaultOrderBy = property.Object.DefaultOrderBy; if (defaultOrderBy.Count > 0) @@ -402,8 +437,8 @@ string mapCall() => property.Object.IsDto { // If we know for sure that we're loading these things (becuse the IncludeTree said so), // but EF didn't load any, then add a blank collection so the client will delete any that already exist. - sb.Line($"}} else if (propVal{name} == null && tree?[nameof({objectName}.{name})] != null) {{"); - sb.Indented($"{objectName}.{name} = new {property.Object.DtoName}[0];"); + sb.Line($"}} else if (propVal{name} == null && tree?[nameof({dtoVar}.{name})] != null) {{"); + sb.Indented($"{dtoVar}.{name} = new {property.Object.DtoName}[0];"); sb.Line("}"); } else @@ -422,14 +457,14 @@ string mapCall() => property.Object.IsDto { // Collection types which emit properly compatible property types on the DTO. // No coersion to a real collection type required. - setter = $"{objectName}.{name} = obj.{name};"; + setter = $"{dtoVar}.{name} = obj.{name};"; } else { // Collection is not really a collection. Probably an IEnumerable. // We will have emitted the property type as ICollection, // so we need to do a ToList() so that it can be assigned. - setter = $"{objectName}.{name} = obj.{name}?.ToList();"; + setter = $"{dtoVar}.{name} = obj.{name}?.ToList();"; } } @@ -439,27 +474,20 @@ string mapCall() => property.Object.IsDto // Only check the includes tree for things that are in the database. // Otherwise, this would break IncludesExternal. string treeCheck = property.Type.ClassViewModel.HasDbSet - ? $"if (tree == null || tree[nameof({objectName}.{name})] != null)" + ? $"if (tree == null || tree[nameof({dtoVar}.{name})] != null)" : ""; setter = $@"{treeCheck} - {objectName}.{name} = obj.{name}{mapCall()}; + {dtoVar}.{name} = obj.{name}{mapCall()}; "; } else { - setter = $"{objectName}.{name} = obj.{name};"; + setter = $"{dtoVar}.{name} = obj.{name};"; } - var statement = GetPropertySetterConditional(property, property.SecurityInfo.Read); - if (!string.IsNullOrWhiteSpace(statement)) - { - return (statement, setter); - } - else - { - return (null, setter); - } + var statement = GetPropertySetterConditional(property, property.SecurityInfo.Read, "obj"); + return (statement, setter); } } } diff --git a/src/IntelliTect.Coalesce.Tests/TargetClasses/SecurityTargets.cs b/src/IntelliTect.Coalesce.Tests/TargetClasses/SecurityTargets.cs index 6e9d04e5d..7d042dbf5 100644 --- a/src/IntelliTect.Coalesce.Tests/TargetClasses/SecurityTargets.cs +++ b/src/IntelliTect.Coalesce.Tests/TargetClasses/SecurityTargets.cs @@ -38,6 +38,32 @@ public class PropSec [Read("ReadRole"), Edit("EditRole")] public string ReadWriteDifferentRoles { get; set; } + [Restrict] + public string RestrictFilter { get; set; } + + [Restrict] + [Restrict] + public string RestrictMultiple { get; set; } + public DbSetIsInternalUse ExternalTypeUsageOfEntityWithInternalDbSet { get; set; } } + + public class AuthenticatedRestriction : IPropertyRestriction + { + public bool UserCanFilter(IMappingContext context, string propertyName) + => context.User.Identity?.IsAuthenticated == true; + + public bool UserCanRead(IMappingContext context, string propertyName, object model) + => context.User.Identity?.IsAuthenticated == true; + + public bool UserCanWrite(IMappingContext context, string propertyName, object model, object incomingValue) + => context.User.Identity?.IsAuthenticated == true; + } + + public class Restrict2 : IPropertyRestriction + { + public bool UserCanRead(IMappingContext context, string propertyName, object model) => true; + + public bool UserCanWrite(IMappingContext context, string propertyName, object model, object incomingValue) => true; + } } diff --git a/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/ComplexModel.cs b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/ComplexModel.cs index 29ae9c200..efa883866 100644 --- a/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/ComplexModel.cs +++ b/src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/ComplexModel.cs @@ -61,6 +61,12 @@ public class ComplexModel [Read(RoleNames.Admin)] public string AdminReadableString { get; set; } + [Restrict] + public string RestrictedString { get; set; } + + [Restrict] + public string RestrictInit { get; init; } + [Read(RoleNames.Admin)] public int? AdminReadableReferenceNavigationId { get; set; } diff --git a/src/IntelliTect.Coalesce.Tests/Tests/Api/DataSources/StandardDataSourceTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/Api/DataSources/StandardDataSourceTests.cs index 7762b9649..120e3a320 100644 --- a/src/IntelliTect.Coalesce.Tests/Tests/Api/DataSources/StandardDataSourceTests.cs +++ b/src/IntelliTect.Coalesce.Tests/Tests/Api/DataSources/StandardDataSourceTests.cs @@ -6,6 +6,7 @@ using IntelliTect.Coalesce.TypeDefinition; using System; using System.Collections.Generic; +using System.Data; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -123,7 +124,20 @@ public void ApplyListPropertyFilters_WhenPropNotAuthorized_IgnoresProp() // Preconditions Assert.False(CrudContext.User.IsInRole(role)); Assert.Collection(prop.SecurityInfo.Read.RoleList, r => Assert.Equal(role, r)); - + + Assert.Single(query); + } + + [Fact] + public void ApplyListPropertyFilters_WhenPropRestricted_IgnoresProp() + { + var (prop, query) = PropertyFiltersTestHelper( + m => m.RestrictedString, "propValue", "inputValue"); + + // Preconditions + Assert.NotEqual(true, CrudContext.User.Identity?.IsAuthenticated); + Assert.NotEmpty(prop.SecurityInfo.Restrictions); + Assert.Single(query); } @@ -141,8 +155,22 @@ public void ApplyListPropertyFilters_WhenPropAuthorized_FiltersProp() Assert.Empty(query); } - - + + [Fact] + public void ApplyListPropertyFilters_WhenPropRestrictionPasses_FiltersProp() + { + CrudContext.User.AddIdentity(new ClaimsIdentity("foo")); + var (prop, query) = PropertyFiltersTestHelper( + m => m.RestrictedString, "propValue", "inputValue"); + + // Preconditions + Assert.True(CrudContext.User.Identity?.IsAuthenticated); + Assert.NotEmpty(prop.SecurityInfo.Restrictions); + + Assert.Empty(query); + } + + public static IEnumerable Filter_MatchesDateTimesData = new[] { new object[] { true, "2017-08-02", new DateTime(2017, 08, 02, 0, 0, 0) }, @@ -360,6 +388,12 @@ public void ApplyListClientSpecifiedSorting_ChecksPropAuthorization() source.Query().AssertOrder(models); // Order should do nothing because the prop is unauthorized. + source + .Query(s => s.ApplyListSorting(s.Query(), new ListParameters + { + OrderBy = $"{nameof(ComplexModel.RestrictedString)}" + })) + .AssertOrder(models); source .Query(s => s.ApplyListSorting(s.Query(), new ListParameters { @@ -555,5 +589,19 @@ public void ApplyListSearchTerm_WhenTargetedPropNotAuthorized_IgnoresProp() // Since this search term doesn't match the property's value, the results should be empty. query.AssertMatched(false); } + + [Fact] + public void ApplyListSearchTerm_WhenTargetedPropRestricted_IgnoresProp() + { + var query = Source() + .AddModel(m => m.RestrictedString, "propValue", out PropertyViewModel prop) + .Query(s => s.ApplyListSearchTerm(s.Query(), new FilterParameters + { + Search = "RestrictedString:propV" + })); + + // Since this search term doesn't match the property's value, the results should be empty. + query.AssertMatched(false); + } } } diff --git a/src/IntelliTect.Coalesce.Tests/Tests/Api/SearchTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/Api/SearchTests.cs index 8f4438fd4..b0f872929 100644 --- a/src/IntelliTect.Coalesce.Tests/Tests/Api/SearchTests.cs +++ b/src/IntelliTect.Coalesce.Tests/Tests/Api/SearchTests.cs @@ -179,12 +179,12 @@ private void SearchHelper( { var classViewModel = new ReflectionClassViewModel(typeof(T)); var prop = classViewModel.PropertyBySelector(propSelector); + var context = new CrudContext(() => new ClaimsPrincipal(), timeZoneInfo ?? TimeZoneInfo.Local); var searchClauses = prop .SearchProperties(classViewModel, maxDepth: 1, force: true) .SelectMany(p => p.GetLinqDynamicSearchStatements( - new ClaimsPrincipal(), - timeZoneInfo ?? TimeZoneInfo.Local, + context, "it", searchTerm )) diff --git a/src/IntelliTect.Coalesce.Tests/Tests/Security/PropertySecurityTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/Security/PropertySecurityTests.cs index 297e72190..829e78119 100644 --- a/src/IntelliTect.Coalesce.Tests/Tests/Security/PropertySecurityTests.cs +++ b/src/IntelliTect.Coalesce.Tests/Tests/Security/PropertySecurityTests.cs @@ -11,20 +11,20 @@ using Moq; using System.Linq.Expressions; using IntelliTect.Coalesce.TypeDefinition.Enums; +using IntelliTect.Coalesce.Mapping; namespace IntelliTect.Coalesce.Tests.Tests.Security { public class PropertySecurityTests { [Theory] - [ClassViewModelData(typeof(PropSec), nameof(PropSec.ReadOnlyViaRead))] - [ClassViewModelData(typeof(PropSec), nameof(PropSec.ReadOnlyViaReadOnly))] - [ClassViewModelData(typeof(PropSec), nameof(PropSec.ReadOnlyViaReadOnlyApi))] - [ClassViewModelData(typeof(PropSec), nameof(PropSec.ReadOnlyViaNonPublicSetter))] - public void ReadOnly_CorrectForReadOnlyProps(ClassViewModelData data, string propName) + [PropertyViewModelData(nameof(PropSec.ReadOnlyViaRead))] + [PropertyViewModelData(nameof(PropSec.ReadOnlyViaReadOnly))] + [PropertyViewModelData(nameof(PropSec.ReadOnlyViaReadOnlyApi))] + [PropertyViewModelData(nameof(PropSec.ReadOnlyViaNonPublicSetter))] + public void ReadOnly_CorrectForReadOnlyProps(PropertyViewModelData data) { - ClassViewModel vm = data; - var prop = vm.PropertyByName(propName); + PropertyViewModel prop = data; Assert.True(prop.IsReadOnly); Assert.False(prop.IsClientSerializable); @@ -32,35 +32,33 @@ public void ReadOnly_CorrectForReadOnlyProps(ClassViewModelData data, string pro } [Theory] - [ClassViewModelData(typeof(PropSec))] - public void ReadWriteDifferentExplicitRoles_RequiresBothRolesForEdit(ClassViewModelData data) + [PropertyViewModelData(nameof(PropSec.ReadWriteDifferentRoles))] + public void ReadWriteDifferentExplicitRoles_RequiresBothRolesForEdit(PropertyViewModelData data) { - ClassViewModel vm = data; - var prop = vm.PropertyByName(nameof(PropSec.ReadWriteDifferentRoles)); + PropertyViewModel prop = data; Assert.Collection(prop.SecurityInfo.Edit.RoleLists, l => Assert.Equal(new[] { "ReadRole" }, l), l => Assert.Equal(new[] { "EditRole" }, l) ); - Assert.True(prop.SecurityInfo.IsEditAllowed(new (new ClaimsIdentity(new[] + Assert.True(prop.SecurityInfo.Edit.IsAllowed(new (new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "ReadRole"), new Claim(ClaimTypes.Role, "EditRole"), })))); - Assert.False(prop.SecurityInfo.IsEditAllowed(new (new ClaimsIdentity(new[] + Assert.False(prop.SecurityInfo.Edit.IsAllowed(new (new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "EditRole"), })))); } [Theory] - [ClassViewModelData(typeof(PropSec))] - public void UsageOfEntityWithInternalDbSet_IsTreatedLikeExternalType(ClassViewModelData data) + [PropertyViewModelData(nameof(PropSec.ExternalTypeUsageOfEntityWithInternalDbSet))] + public void UsageOfEntityWithInternalDbSet_IsTreatedLikeExternalType(PropertyViewModelData data) { - ClassViewModel vm = data; - var prop = vm.PropertyByName(nameof(PropSec.ExternalTypeUsageOfEntityWithInternalDbSet)); + PropertyViewModel prop = data; Assert.False(prop.IsDbMapped); Assert.False(prop.Object.IsDbMappedType); @@ -73,7 +71,33 @@ public void UsageOfEntityWithInternalDbSet_IsTreatedLikeExternalType(ClassViewMo // Both PropSec and DbSetIsInternalUse are considered external types, // so the property should be accepted as input Assert.True(prop.SecurityInfo.Edit.IsAllowed()); + } + + [Theory] + [PropertyViewModelData(nameof(PropSec.RestrictMultiple))] + public void MappingRestrictions_AreDiscovered(PropertyViewModelData data) + { + PropertyViewModel prop = data; + + Assert.Collection(prop.SecurityInfo.Restrictions, + r => + { + Assert.Equal(typeof(AuthenticatedRestriction).Name, r.Name); + }, + r => + { + Assert.Equal(typeof(Restrict2).Name, r.Name); + }); + } + + [Theory] + [ReflectionPropertyViewModelData(nameof(PropSec.RestrictFilter))] + public void MappingRestrictions_UserCanFilter_RespectsRestrictions(PropertyViewModelData data) + { + PropertyViewModel prop = data; + Assert.False(prop.SecurityInfo.IsFilterAllowed(new MappingContext(new ClaimsPrincipal(), null))); + Assert.True(prop.SecurityInfo.IsFilterAllowed(new MappingContext(new ClaimsPrincipal(new ClaimsIdentity("foo")), null))); } [Theory] diff --git a/src/IntelliTect.Coalesce.Tests/Util/PropertyViewModelDataAttribute.cs b/src/IntelliTect.Coalesce.Tests/Util/PropertyViewModelDataAttribute.cs index 9d1416316..60a7bca96 100644 --- a/src/IntelliTect.Coalesce.Tests/Util/PropertyViewModelDataAttribute.cs +++ b/src/IntelliTect.Coalesce.Tests/Util/PropertyViewModelDataAttribute.cs @@ -40,4 +40,12 @@ public PropertyViewModelDataAttribute(string propName, params object[] additiona { } } + + internal class ReflectionPropertyViewModelDataAttribute : PropertyViewModelDataAttribute + { + public ReflectionPropertyViewModelDataAttribute(string propName, params object[] additionalInlineData) : base(propName, additionalInlineData) + { + symbol = false; + } + } } diff --git a/src/IntelliTect.Coalesce/Api/CrudContext.cs b/src/IntelliTect.Coalesce/Api/CrudContext.cs index 77255d224..079d6af6b 100644 --- a/src/IntelliTect.Coalesce/Api/CrudContext.cs +++ b/src/IntelliTect.Coalesce/Api/CrudContext.cs @@ -1,4 +1,5 @@ -using IntelliTect.Coalesce.TypeDefinition; +using IntelliTect.Coalesce.Mapping; +using IntelliTect.Coalesce.TypeDefinition; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -53,6 +54,16 @@ internal CrudContext(CrudContext baseContext) ServiceProvider = baseContext.ServiceProvider; } + + /// + /// A shared mapping context instance for this operation. + /// Use intended for handling inputs before any mutations (if any) + /// have occurred that might invalidate local state on cached instances + /// of IPropertyRestriction. + /// + internal MappingContext MappingContext => _mappingContext ??= new MappingContext(this); + private MappingContext? _mappingContext; + /// /// The user making the request for a CRUD action. /// @@ -89,7 +100,7 @@ internal CrudContext(CrudContext baseContext) public class CrudContext : CrudContext where TContext : DbContext { - public CrudContext(TContext dbContext, Func userAccessor) + public CrudContext(TContext dbContext, Func userAccessor) : base(userAccessor) { DbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); @@ -97,7 +108,7 @@ public CrudContext(TContext dbContext, Func userAccessor) public CrudContext( TContext dbContext, - Func userAccessor, + Func userAccessor, TimeZoneInfo timeZone, CancellationToken cancellationToken = default ) diff --git a/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs b/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs index 52b2a29ee..85e804682 100644 --- a/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs +++ b/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs @@ -73,7 +73,7 @@ public virtual IQueryable ApplyListPropertyFilters(IQueryable query, IFilt var prop = ClassViewModel.PropertyByName(clause.Key); if (prop != null && prop.IsUrlFilterParameter - && prop.SecurityInfo.IsReadAllowed(User)) + && prop.SecurityInfo.IsFilterAllowed(Context)) { query = ApplyListPropertyFilter(query, prop, clause.Value); } @@ -269,7 +269,7 @@ public virtual IQueryable ApplyListSearchTerm(IQueryable query, IFilterPar { var expressions = prop .SearchProperties(ClassViewModel, maxDepth: 1, force: true) - .SelectMany(p => p.GetLinqDynamicSearchStatements(Context.User, Context.TimeZone, "it", value)) + .SelectMany(p => p.GetLinqDynamicSearchStatements(Context, "it", value)) .Select(t => t.statement) .ToList(); @@ -303,7 +303,7 @@ public virtual IQueryable ApplyListSearchTerm(IQueryable query, IFilterPar { var splitOnStringClauses = ClassViewModel .SearchProperties(ClassViewModel) - .SelectMany(p => p.GetLinqDynamicSearchStatements(Context.User, Context.TimeZone, "it", termWord)) + .SelectMany(p => p.GetLinqDynamicSearchStatements(Context, "it", termWord)) .Where(f => f.property.SearchIsSplitOnSpaces) .Select(t => t.statement) .ToList(); @@ -328,7 +328,7 @@ public virtual IQueryable ApplyListSearchTerm(IQueryable query, IFilterPar // we only require that the entire search term match at least one of these properties. var searchClauses = ClassViewModel .SearchProperties(ClassViewModel) - .SelectMany(p => p.GetLinqDynamicSearchStatements(Context.User, Context.TimeZone, "it", searchTerm)) + .SelectMany(p => p.GetLinqDynamicSearchStatements(Context, "it", searchTerm)) .Where(f => !f.property.SearchIsSplitOnSpaces) .Select(t => t.statement) .ToList(); @@ -405,7 +405,7 @@ public virtual IQueryable ApplyListClientSpecifiedSorting(IQueryable query prop = (prop?.Object ?? ClassViewModel).PropertyByName(part); // Check if the new prop exists and is readable by user. - if (prop == null || !prop.IsClientProperty || !prop.SecurityInfo.IsReadAllowed(User)) + if (prop == null || !prop.IsClientProperty || !prop.SecurityInfo.IsFilterAllowed(Context)) { return null; } diff --git a/src/IntelliTect.Coalesce/Application/CoalesceApplicationBuilderExtensions.cs b/src/IntelliTect.Coalesce/Application/CoalesceApplicationBuilderExtensions.cs index 538d26542..7cced1728 100644 --- a/src/IntelliTect.Coalesce/Application/CoalesceApplicationBuilderExtensions.cs +++ b/src/IntelliTect.Coalesce/Application/CoalesceApplicationBuilderExtensions.cs @@ -65,7 +65,8 @@ internal static object GetSecurityOverviewData(ReflectionRepository repo, IDataS { CrudTypes = repo .CrudApiBackedClasses - .OrderBy(c => c.Name) + .OrderBy(c => c.IsDto) + .ThenBy(c => c.Name) .Select(c => { var isImmutable = c.SecurityInfo is @@ -181,6 +182,7 @@ string StripNamespacesFromTypeParams(string s) => s.IndexOf('<') is int start an p.SecurityInfo.Read, p.SecurityInfo.Init, p.SecurityInfo.Edit, + Restrictions = p.SecurityInfo.Restrictions.Select(t => t.Name).ToList(), p.IsCreateOnly, p.IsPrimaryKey }; diff --git a/src/IntelliTect.Coalesce/Application/CoalesceServiceCollectionExtensions.cs b/src/IntelliTect.Coalesce/Application/CoalesceServiceCollectionExtensions.cs index 89be8d72a..7d537bfb5 100644 --- a/src/IntelliTect.Coalesce/Application/CoalesceServiceCollectionExtensions.cs +++ b/src/IntelliTect.Coalesce/Application/CoalesceServiceCollectionExtensions.cs @@ -64,7 +64,7 @@ void AddFactoryDefaultTypes(IDictionary types) services.TryAddScoped(_ => new StaticTimeZoneResolver(TimeZoneInfo.Local)); services.TryAddScoped(sp => new CrudContext( - () => sp.GetRequiredService().HttpContext?.User, + () => sp.GetRequiredService().HttpContext?.User ?? new System.Security.Claims.ClaimsPrincipal(), sp.GetService()?.GetTimeZoneInfo() ?? TimeZoneInfo.Local, sp.GetRequiredService().HttpContext?.RequestAborted ?? default, sp.GetRequiredService>().Value, diff --git a/src/IntelliTect.Coalesce/Application/SecurityOverview.html b/src/IntelliTect.Coalesce/Application/SecurityOverview.html index b46251ba4..7a2b7445a 100644 --- a/src/IntelliTect.Coalesce/Application/SecurityOverview.html +++ b/src/IntelliTect.Coalesce/Application/SecurityOverview.html @@ -7,11 +7,29 @@ Coalesce Security Overview +