Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions Source/Rendering.Cratis/Authorization/ReadModelAuthorization.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Renders the authorization attribute guarding one read model's rendered read surface, from the queries that
/// read <b>that</b> read model.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>OverdueInvoices</c> says
/// nothing about who may read <c>InvoiceSummary</c>, so the union guarding a read model is taken over its own
/// queries and no others.
/// </para>
/// <para>
/// The attribution is a correctness concern rather than a tidy one, because getting it wrong fails in the
/// <b>permissive</b> direction. <see cref="AuthorizationRenderer"/> collapses a union to <c>AllowAnonymous</c>
/// 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.
/// </para>
/// <para>
/// 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 <c>AllowAnonymous</c> that
/// absence has always rendered as.
/// </para>
/// </remarks>
public static class ReadModelAuthorization
{
/// <summary>
/// Renders the authorization attribute for a read model, from the queries that return it.
/// </summary>
/// <param name="readModel">The rendered read model's C# type name.</param>
/// <param name="queries">Every <see cref="QuerySyntax"/> the slice declares, across all of its read models.</param>
/// <param name="applicationSet">The <see cref="ApplicationSet"/> the policies are resolved against.</param>
/// <param name="diagnostics">Collects anything that could not be rendered faithfully.</param>
/// <returns>The attribute content, without the surrounding brackets.</returns>
public static string Render(
string readModel, IEnumerable<QuerySyntax> queries, ApplicationSet applicationSet, ICollection<string> 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);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="query">The <see cref="QuerySyntax"/> to attribute.</param>
/// <param name="readModel">The rendered read model's C# type name.</param>
/// <returns>True when the query reads that read model.</returns>
static bool Reads(QuerySyntax query, string readModel) =>
Identifiers.ToPascalCase(query.ReturnType.Name).Equals(readModel, StringComparison.Ordinal);
}
18 changes: 9 additions & 9 deletions Source/Rendering.Cratis/Renderers/StateViewSliceRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ namespace Cratis.Stage.Rendering.Cratis.Renderers;
/// <remarks>
/// The slice's declared <c>query</c> 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 <b>union</b> 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 <b>union</b> of what the queries that read <i>it</i>
/// 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 <see cref="ReadModelAuthorization"/>'s job. That the declared queries
/// themselves are missing is reported separately.
/// </remarks>
public class StateViewSliceRenderer : ISliceRenderer
{
Expand All @@ -43,10 +44,10 @@ public RenderedFile Render(LocatedSlice slice, ApplicationSet applicationSet, st

var referenced = new List<string>(EventRenderer.ReferencedNames(slice.Slice.Events));

// A slice may declare several projections. Only the first is renderedthe 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);
Expand Down Expand Up @@ -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})");
Expand Down
2 changes: 1 addition & 1 deletion Source/Rendering.Cratis/Renderers/UnrenderedConstructs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public class an_application_with_policies : Specification
{
protected ApplicationSet _applicationSet = null!;
protected List<string> _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 = [];
}

/// <summary>
/// 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.
/// </summary>
/// <param name="name">The name of the query.</param>
/// <param name="readModel">The read model the query reads, which its return type names.</param>
/// <param name="policies">The policies any one of which may read it; none leaves the query unguarded.</param>
/// <returns>The <see cref="QuerySyntax"/>.</returns>
protected static QuerySyntax Query(string name, string readModel, params string[] policies) =>
Build(name, readModel, isCollection: false, policies);

/// <summary>
/// Builds a query answering with a collection of a read model, which reads it just as a single instance does.
/// </summary>
/// <param name="name">The name of the query.</param>
/// <param name="readModel">The read model the query reads, which its return type names.</param>
/// <param name="policies">The policies any one of which may read it; none leaves the query unguarded.</param>
/// <returns>The <see cref="QuerySyntax"/>.</returns>
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);
}
Original file line number Diff line number Diff line change
@@ -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\")");
}
Original file line number Diff line number Diff line change
@@ -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\")");
}
Original file line number Diff line number Diff line change
@@ -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.");
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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");
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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();
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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);
}
Loading
Loading