diff --git a/Source/Rendering.Cratis/Authorization/ReadModelAuthorization.cs b/Source/Rendering.Cratis/Authorization/ReadModelAuthorization.cs new file mode 100644 index 0000000..43e789c --- /dev/null +++ b/Source/Rendering.Cratis/Authorization/ReadModelAuthorization.cs @@ -0,0 +1,74 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Syntax; +using Cratis.Stage.Rendering.Cratis.Naming; + +namespace Cratis.Stage.Rendering.Cratis.Authorization; + +/// +/// Renders the authorization attribute guarding one read model's rendered read surface, from the queries that +/// read that read model. +/// +/// +/// +/// A slice declares as many read models as its behavior needs, and a query names the one it reads with its +/// return type. Authorization has to follow that attribution: a query returning OverdueInvoices says +/// nothing about who may read InvoiceSummary, so the union guarding a read model is taken over its own +/// queries and no others. +/// +/// +/// The attribution is a correctness concern rather than a tidy one, because getting it wrong fails in the +/// permissive direction. collapses a union to AllowAnonymous +/// as soon as one member is unguarded — correctly, for queries that genuinely read the same model — so drawing +/// the union from every query in the slice let a single unguarded query on an unrelated read model publish a +/// guarded one to everyone. +/// +/// +/// When the slice declares queries but none of them return this read model, the document has stated who may +/// read the slice's other models and nothing about this one. Reading that silence as anonymous would take +/// permission from a document that never granted it, so it falls back to requiring an authenticated caller and +/// reports it — the same fallback used for every other requirement no attribute expresses faithfully. A slice +/// declaring no query at all says nothing about reading anything, and keeps the AllowAnonymous that +/// absence has always rendered as. +/// +/// +public static class ReadModelAuthorization +{ + /// + /// Renders the authorization attribute for a read model, from the queries that return it. + /// + /// The rendered read model's C# type name. + /// Every the slice declares, across all of its read models. + /// The the policies are resolved against. + /// Collects anything that could not be rendered faithfully. + /// The attribute content, without the surrounding brackets. + public static string Render( + string readModel, IEnumerable queries, ApplicationSet applicationSet, ICollection diagnostics) + { + var declared = queries.ToArray(); + var own = declared.Where(query => Reads(query, readModel)).ToArray(); + var subject = $"Read model '{readModel}'"; + + if (own.Length == 0 && declared.Length > 0) + { + diagnostics.Add( + $"{subject} is returned by none of the {declared.Length} query declaration(s) in its slice — the " + + "document states who may read the other read models and nothing about this one, so its rendered " + + "read surface requires an authenticated caller rather than being left open to everyone."); + return "Authorize"; + } + + return AuthorizationRenderer.Render(own.Select(query => query.Authorize), applicationSet, subject, diagnostics); + } + + /// + /// Whether a query reads the given read model. The return type names it, whether the query answers with one + /// instance or a collection of them. + /// + /// The to attribute. + /// The rendered read model's C# type name. + /// True when the query reads that read model. + static bool Reads(QuerySyntax query, string readModel) => + Identifiers.ToPascalCase(query.ReturnType.Name).Equals(readModel, StringComparison.Ordinal); +} diff --git a/Source/Rendering.Cratis/Renderers/StateViewSliceRenderer.cs b/Source/Rendering.Cratis/Renderers/StateViewSliceRenderer.cs index 3f2f70b..d8083ab 100644 --- a/Source/Rendering.Cratis/Renderers/StateViewSliceRenderer.cs +++ b/Source/Rendering.Cratis/Renderers/StateViewSliceRenderer.cs @@ -21,9 +21,10 @@ namespace Cratis.Stage.Rendering.Cratis.Renderers; /// /// The slice's declared query blocks are not rendered — the read model gets a fixed all/by-id pair /// instead — so the authorization those queries declare would have nowhere to land and the read surface would be -/// open to everyone. The read model therefore carries the union of what its declared queries permit: every -/// caller the document lets read this model through some query can still read it, and nobody else can. That the -/// declared queries themselves are missing is reported separately. +/// open to everyone. The read model therefore carries the union of what the queries that read it +/// permit: every caller the document lets read this model through some query can still read it, and nobody else +/// can. Which queries those are is 's job. That the declared queries +/// themselves are missing is reported separately. /// public class StateViewSliceRenderer : ISliceRenderer { @@ -43,10 +44,10 @@ public RenderedFile Render(LocatedSlice slice, ApplicationSet applicationSet, st var referenced = new List(EventRenderer.ReferencedNames(slice.Slice.Events)); - // A slice may declare several projections. Only the first is rendered — the read model carries a fixed - // all/by-id pair with no way to say which projection a query belongs to, so rendering the rest would - // guard each of them with the same union of every query's authorization. The ones left out are reported - // by UnrenderedConstructs rather than dropped in silence. + // A slice may declare several projections. Only the first is rendered; the ones left out are reported by + // UnrenderedConstructs rather than dropped in silence. Authorization is no longer what holds this back — + // a query names the read model it reads with its return type, so each read model's guard is attributable + // and the ones that are dropped no longer widen the one that survives. if (slice.Slice.Projections.FirstOrDefault() is { } projection) { RenderReadModel(builder, projection, slice.Slice.Queries, applicationSet, referenced, diagnostics); @@ -106,8 +107,7 @@ static void RenderReadModel( ReportUnrenderedBlocks(builder, projection, typeName, diagnostics); - var authorization = AuthorizationRenderer.Render( - queries.Select(query => query.Authorize), applicationSet, $"Read model '{typeName}'", diagnostics); + var authorization = ReadModelAuthorization.Render(typeName, queries, applicationSet, diagnostics); var parameters = string.Join(", ", properties.Select(property => RenderParameter(property, keyProperty))); builder.Attribute("ReadModel").Attribute(authorization).OpenBlock($"public record {typeName}({parameters})"); diff --git a/Source/Rendering.Cratis/Renderers/UnrenderedConstructs.cs b/Source/Rendering.Cratis/Renderers/UnrenderedConstructs.cs index 4a129ab..260b3d3 100644 --- a/Source/Rendering.Cratis/Renderers/UnrenderedConstructs.cs +++ b/Source/Rendering.Cratis/Renderers/UnrenderedConstructs.cs @@ -63,7 +63,7 @@ public static void Report(CSharpCodeBuilder builder, SliceSyntax slice, Rendered yield return ( slice.Queries.Count(), "query", - "the read model carries the fixed all/by-id pair instead, guarded by the union of the declared queries' authorization."); + "the read model carries the fixed all/by-id pair instead, guarded by the union of the authorization declared by the queries that read it."); yield return ( slice.Queries.Count(query => query.Performer is not null), "query performer", diff --git a/Source/Rendering.Cratis/for_ReadModelAuthorization/given/an_application_with_policies.cs b/Source/Rendering.Cratis/for_ReadModelAuthorization/given/an_application_with_policies.cs new file mode 100644 index 0000000..eb38ab2 --- /dev/null +++ b/Source/Rendering.Cratis/for_ReadModelAuthorization/given/an_application_with_policies.cs @@ -0,0 +1,74 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.Authorization; + +namespace Cratis.Stage.Rendering.Cratis.for_ReadModelAuthorization.given; + +/// +/// An application declaring the one role policy these specs authorize against, and the helpers for hanging +/// query declarations off the read models a slice declares. +/// +public class an_application_with_policies : Specification +{ + protected ApplicationSet _applicationSet = null!; + protected List _diagnostics = null!; + + void Establish() + { + var application = new ApplicationSyntax( + [], + [], + [new PolicySyntax("Accountant", new RoleConditionSyntax("Accountant", SourceLocation.Start), null, SourceLocation.Start)], + [], + SourceLocation.Start); + + _applicationSet = new ApplicationSet([application]); + _diagnostics = []; + } + + /// + /// Builds a query answering with one instance of a read model, guarded by the named policies — or by nothing + /// at all when none are named. + /// + /// The name of the query. + /// The read model the query reads, which its return type names. + /// The policies any one of which may read it; none leaves the query unguarded. + /// The . + protected static QuerySyntax Query(string name, string readModel, params string[] policies) => + Build(name, readModel, isCollection: false, policies); + + /// + /// Builds a query answering with a collection of a read model, which reads it just as a single instance does. + /// + /// The name of the query. + /// The read model the query reads, which its return type names. + /// The policies any one of which may read it; none leaves the query unguarded. + /// The . + protected static QuerySyntax QueryForMany(string name, string readModel, params string[] policies) => + Build(name, readModel, isCollection: true, policies); + + static QuerySyntax Build(string name, string readModel, bool isCollection, string[] policies) => + new( + name, + new TypeRefSyntax(readModel, isCollection, false, SourceLocation.Start), + null, + [], + Authorize(policies), + SourceLocation.Start); + + static AuthorizeSyntax? Authorize(string[] policies) => + policies.Length == 0 + ? null + : new AuthorizeSyntax( + policies + .Select(policy => (PolicyRequirementSyntax)new PolicyReferenceSyntax(policy, SourceLocation.Start)) + .Aggregate((left, right) => new LogicalPolicyRequirementSyntax(left, LogicalOperator.Or, right, SourceLocation.Start)), + SourceLocation.Start); + + protected string Render(string readModel, params QuerySyntax[] queries) => + ReadModelAuthorization.Render(readModel, queries, _applicationSet, _diagnostics); +} diff --git a/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_another_read_model_has_an_unguarded_query.cs b/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_another_read_model_has_an_unguarded_query.cs new file mode 100644 index 0000000..94c7899 --- /dev/null +++ b/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_another_read_model_has_an_unguarded_query.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.for_ReadModelAuthorization.given; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_ReadModelAuthorization.when_rendering_a_read_models_authorization; + +public class and_another_read_model_has_an_unguarded_query : an_application_with_policies +{ + string _attribute = null!; + + void Because() => _attribute = Render( + "InvoiceSummary", + Query("GetInvoiceSummary", "InvoiceSummary", "Accountant"), + QueryForMany("GetOverdueInvoices", "OverdueInvoices")); + + [Fact] void should_keep_the_guard_its_own_query_declares() => _attribute.ShouldEqual("Roles(\"Accountant\")"); +} diff --git a/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_its_query_answers_with_a_collection.cs b/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_its_query_answers_with_a_collection.cs new file mode 100644 index 0000000..8ed4588 --- /dev/null +++ b/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_its_query_answers_with_a_collection.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.for_ReadModelAuthorization.given; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_ReadModelAuthorization.when_rendering_a_read_models_authorization; + +public class and_its_query_answers_with_a_collection : an_application_with_policies +{ + string _attribute = null!; + + void Because() => _attribute = Render("InvoiceList", QueryForMany("ListInvoices", "InvoiceList", "Accountant")); + + [Fact] void should_attribute_it_to_the_read_model_the_return_type_names() => _attribute.ShouldEqual("Roles(\"Accountant\")"); +} diff --git a/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_no_query_returns_it.cs b/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_no_query_returns_it.cs new file mode 100644 index 0000000..27d5750 --- /dev/null +++ b/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_no_query_returns_it.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.for_ReadModelAuthorization.given; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_ReadModelAuthorization.when_rendering_a_read_models_authorization; + +public class and_no_query_returns_it : an_application_with_policies +{ + string _attribute = null!; + + void Because() => _attribute = Render("InvoiceSummary", QueryForMany("GetOverdueInvoices", "OverdueInvoices", "Accountant")); + + [Fact] void should_require_an_authenticated_caller() => _attribute.ShouldEqual("Authorize"); + [Fact] void should_report_that_nothing_states_who_may_read_it() => + _diagnostics.ShouldContain( + "Read model 'InvoiceSummary' is returned by none of the 1 query declaration(s) in its slice — the document " + + "states who may read the other read models and nothing about this one, so its rendered read surface " + + "requires an authenticated caller rather than being left open to everyone."); +} diff --git a/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_one_of_its_own_queries_is_unguarded.cs b/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_one_of_its_own_queries_is_unguarded.cs new file mode 100644 index 0000000..dbb056a --- /dev/null +++ b/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_one_of_its_own_queries_is_unguarded.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.for_ReadModelAuthorization.given; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_ReadModelAuthorization.when_rendering_a_read_models_authorization; + +/// +/// The collapse to anonymous is correct for queries that genuinely read the same read model — the document +/// lets anyone read it through the unguarded one, so the rendered pair standing in for both must too. +/// +public class and_one_of_its_own_queries_is_unguarded : an_application_with_policies +{ + string _attribute = null!; + + void Because() => _attribute = Render( + "InvoiceSummary", + Query("GetInvoiceSummary", "InvoiceSummary", "Accountant"), + Query("PeekInvoiceSummary", "InvoiceSummary")); + + [Fact] void should_follow_the_query_that_asks_for_nothing() => _attribute.ShouldEqual("AllowAnonymous"); +} diff --git a/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_the_slice_declares_no_queries.cs b/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_the_slice_declares_no_queries.cs new file mode 100644 index 0000000..7d715fe --- /dev/null +++ b/Source/Rendering.Cratis/for_ReadModelAuthorization/when_rendering_a_read_models_authorization/and_the_slice_declares_no_queries.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.for_ReadModelAuthorization.given; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_ReadModelAuthorization.when_rendering_a_read_models_authorization; + +/// +/// A slice declaring no query says nothing about reading anything, which is a different silence from one that +/// states read authorization for its other read models and none for this one. +/// +public class and_the_slice_declares_no_queries : an_application_with_policies +{ + string _attribute = null!; + + void Because() => _attribute = Render("InvoiceSummary"); + + [Fact] void should_state_the_absence_as_anonymous() => _attribute.ShouldEqual("AllowAnonymous"); + [Fact] void should_report_nothing() => _diagnostics.ShouldBeEmpty(); +} diff --git a/Source/Rendering.Cratis/for_StateViewSliceRenderer/given/a_slice_with_two_read_models.cs b/Source/Rendering.Cratis/for_StateViewSliceRenderer/given/a_slice_with_two_read_models.cs new file mode 100644 index 0000000..d6963e9 --- /dev/null +++ b/Source/Rendering.Cratis/for_StateViewSliceRenderer/given/a_slice_with_two_read_models.cs @@ -0,0 +1,90 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Syntax; +using Cratis.Screenplay.Syntax.Projections; +using Cratis.Specifications; + +namespace Cratis.Stage.Rendering.Cratis.for_StateViewSliceRenderer.given; + +/// +/// A dashboard slice shaped like the one in the invoicing sample: two projections, and a query for each of the +/// read models they build. The rendered read model's own query is guarded by a role; the other read model's +/// query is not guarded at all. +/// +public class a_slice_with_two_read_models : Specification +{ + protected ApplicationSet _applicationSet = null!; + protected LocatedSlice _dashboardSlice = null!; + + void Establish() + { + var invoiceRegistered = Event("InvoiceRegistered"); + var invoiceMarkedOverdue = Event("InvoiceMarkedOverdue"); + + var summaryProjection = Projection("InvoiceSummary", "InvoiceRegistered"); + var overdueProjection = Projection("OverdueInvoices", "InvoiceMarkedOverdue"); + + var summaryQuery = Query("GetInvoiceSummary", "InvoiceSummary", isCollection: false, policy: "Accountant"); + var overdueQuery = Query("GetOverdueInvoices", "OverdueInvoices", isCollection: true, policy: null); + + var slice = new SliceSyntax( + SliceType.StateView, + "InvoiceDashboard", + [invoiceRegistered, invoiceMarkedOverdue], + [], + [summaryQuery, overdueQuery], + [summaryProjection, overdueProjection], + [], + [], + [], + [], + [], + SourceLocation.Start); + + var feature = new FeatureSyntax("Invoices", [], [slice], SourceLocation.Start); + var module = new ModuleSyntax("Billing", [], [feature], SourceLocation.Start); + var application = new ApplicationSyntax( + [], + [], + [new PolicySyntax("Accountant", new RoleConditionSyntax("Accountant", SourceLocation.Start), null, SourceLocation.Start)], + [module], + SourceLocation.Start); + + _applicationSet = new ApplicationSet([application]); + _dashboardSlice = _applicationSet.Slices.Single(); + } + + static EventSyntax Event(string name) => + new( + name, + [new PropertySyntax("invoiceNumber", new TypeRefSyntax("String", false, false, SourceLocation.Start), SourceLocation.Start)], + SourceLocation.Start); + + static ProjectionSyntax Projection(string readModel, string @event) => + new( + readModel, + readModel, + null, + AutoMapMode.Enabled, + new ExpressionKeySyntax(new PathExpressionSyntax("invoiceNumber", SourceLocation.Start), SourceLocation.Start), + [ + new FromSyntax( + [new EventSpecSyntax(@event, null, SourceLocation.Start)], + null, + null, + [new SetMappingSyntax("invoiceNumber", new PathExpressionSyntax("invoiceNumber", SourceLocation.Start), SourceLocation.Start)], + SourceLocation.Start) + ], + SourceLocation.Start); + + static QuerySyntax Query(string name, string readModel, bool isCollection, string? policy) => + new( + name, + new TypeRefSyntax(readModel, isCollection, false, SourceLocation.Start), + null, + [], + policy is null ? null : new AuthorizeSyntax(new PolicyReferenceSyntax(policy, SourceLocation.Start), SourceLocation.Start), + SourceLocation.Start); +} diff --git a/Source/Rendering.Cratis/for_StateViewSliceRenderer/when_another_read_model_has_an_unguarded_query.cs b/Source/Rendering.Cratis/for_StateViewSliceRenderer/when_another_read_model_has_an_unguarded_query.cs new file mode 100644 index 0000000..7207a52 --- /dev/null +++ b/Source/Rendering.Cratis/for_StateViewSliceRenderer/when_another_read_model_has_an_unguarded_query.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.CodeGeneration; +using Cratis.Stage.Rendering.Cratis.for_StateViewSliceRenderer.given; +using Cratis.Stage.Rendering.Cratis.Renderers; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_StateViewSliceRenderer; + +/// +/// Only the slice's first projection is rendered, so the dropped one's query used to be part of the union +/// guarding the read model that survived — and an unguarded query anywhere in the slice published it to +/// everyone. A query guards the read model its return type names and no other. +/// +public class when_another_read_model_has_an_unguarded_query : a_slice_with_two_read_models +{ + RenderedFile _file = null!; + StateViewSliceRenderer _renderer = null!; + + void Establish() => _renderer = new StateViewSliceRenderer(); + + void Because() => _file = _renderer.Render(_dashboardSlice, _applicationSet, "CratisApp"); + + [Fact] void should_render_the_first_projections_read_model() => _file.Content.ShouldContain("public record InvoiceSummary("); + [Fact] void should_keep_it_guarded_by_its_own_querys_policy() => _file.Content.ShouldContain("[Roles(\"Accountant\")]"); + [Fact] void should_not_publish_it_to_everyone() => _file.Content.Contains("[AllowAnonymous]").ShouldBeFalse(); +} diff --git a/Source/Rendering.Cratis/for_UnrenderedConstructs/when_reporting_what_a_slice_declares.cs b/Source/Rendering.Cratis/for_UnrenderedConstructs/when_reporting_what_a_slice_declares.cs index 79e2560..18325f9 100644 --- a/Source/Rendering.Cratis/for_UnrenderedConstructs/when_reporting_what_a_slice_declares.cs +++ b/Source/Rendering.Cratis/for_UnrenderedConstructs/when_reporting_what_a_slice_declares.cs @@ -41,7 +41,7 @@ [Fact] void should_report_the_reactor() => [Fact] void should_report_the_queries() => _diagnostics.ShouldContain( "Slice 'Summary' declares 1 query declaration(s) with no rendered equivalent — the read model carries the fixed " + - "all/by-id pair instead, guarded by the union of the declared queries' authorization."); + "all/by-id pair instead, guarded by the union of the authorization declared by the queries that read it."); [Fact] void should_report_the_query_performer() => _diagnostics.ShouldContain( "Slice 'Summary' declares 1 query performer declaration(s) with no rendered equivalent — the query logic is not rendered.");