Open sandboxFocusImprove this doc

Enforcing authorization with aspects

This example shows how to enforce a non-trivial authorization model in business objects without writing permission checks by hand. Aspects add the checks to methods, properties, fields and parameters. A run-time policy decides whether the current user has the required permission on the object being accessed.

Warning

This example is a proof of concept. It is not tested for production use. See Limitations.

Two layers: an abstract framework and a role-based model

The framework has two layers.

Layer 1: the abstract authorization layer

The first layer is abstract. It does not assume any security model. It defines five concepts:

Concept Type Description
Subject ISubject The user or service on whose behalf the code runs.
Permission Permission Something that the subject must have on an object, for instance Read, Write or Approve. Permissions are compared by name.
Securable ISecurable The object on which the permission is required. For a method or property, it is the current instance. For a parameter, it is the argument. Every class that implements ISecurable is secured and validated.
Policy IAuthorizationPolicy Decides whether a subject has a permission on a securable.
Security context ISecurityContext The current subject, the policy, and an optional auditor. SecurityContext.Current exposes it.

The aspects, the options and the run-time entry point AccessGuard belong to this layer. They only ask one question: does the current subject have this permission on this object? The answer comes from the IAuthorizationPolicy.IsGranted method, and the abstract layer does not know how the policy computes it.

Layer 2: the role-based model

The second layer is one implementation of IAuthorizationPolicy: the classic role-based access control model, in the RoleBased namespace.

  • Roles are assigned to subjects on an object, for instance "Mia is department manager of Sales".
  • Each object has a security parent and inherits the role assignments of its ancestors. For instance, an invoice inherits the role assignments of its department, and the department inherits those of the company.
  • Rules allow or deny a permission to a role on a type of object, for instance "department managers are allowed to approve invoices". A rule on a base type also applies to derived types.
  • A permission is granted when at least one rule allows it and no rule denies it.
  • Computed roles are never assigned: the object computes them from its own data. Two are built in: every subject has Everyone, and the Owner of an object has Owner on this object only. Applications can add their own, such as the creator of an invoice.

Replacing the role-based model

Other security models can replace the role-based layer without any change to the aspects or to the annotated business code. Only the policy and the securable objects change. For instance:

  • A group-based model, where permissions are granted to groups of users, and a user has the permissions of all the groups they belong to.
  • An access-control-list model, where each object stores the permissions granted directly to users, without an intermediate role.
  • An attribute-based model, where the policy compares properties of the subject and of the object, for instance the department of the user and the department of the invoice.
  • A composite policy, which delegates to several policies and combines their answers.

Project structure

The security framework is separate from the application that uses it:

Project Content
Metalama.Samples.Authorization The framework: the abstract layer (aspects, options, AccessGuard, security context) and the role-based layer. It does not know the invoicing domain.
Metalama.Samples.Authorization.Demo An invoicing application that uses the framework: domain objects stored with Entity Framework Core, application services registered in a dependency injection container, and a console program that runs scenarios.
tests/Metalama.Samples.Authorization.Tests Aspect tests: snapshots of the code that the aspects generate.
tests/Metalama.Samples.Authorization.UnitTests Unit tests of the framework, on small test types. They do not depend on the demo.
tests/Metalama.Samples.Authorization.Demo.UnitTests Unit tests of the security model of the demo application.

The aspects

RequiresPermission

The [RequiresPermission] aspect adds a permission check before a method executes, or before a field or property is read or written. Applied to a parameter, it checks the permission on the argument instead of on the current instance.

Here is the invoice class of the demo, and the code that the aspects generate:

Source Code
1using Metalama.Samples.Authorization.RoleBased;
2
3namespace Metalama.Samples.Authorization.Demo.Sales;
4
5/// <summary>
6/// An invoice issued by a <see cref="Department"/> to a <see cref="Customer"/>.
7/// </summary>
8/// <remarks>
9/// <para>Permission checks come from three sources: the <see cref="SecurableTypeAttribute"/> aspect
10/// inherited from <see cref="Entity"/> (properties need <c>Read</c>/<c>Write</c>), the project fabric (methods
11/// without an attribute need <c>Write</c>), and explicit <see cref="RequiresPermissionAttribute"/> attributes.</para>
12/// <para>Authorization answers "may this user do this?". Business rules, such as "an approved invoice cannot be
13/// edited", are a separate concern and are enforced by the methods themselves, and by
14/// <see cref="ISecurableCollectionOwner.OnCollectionChanging"/> for the lines.</para>
15/// </remarks>
16public sealed class Invoice : Entity, ISecurableCollectionOwner
17{
18    // Used by Entity Framework, which creates entities in a system scope, so the constructor can set secured properties.
19    private Invoice() : this( null!, null!, null!, null ) { }
20
21    /// <summary>
22    /// Creates a draft invoice, owned by its creator. <see cref="Demo.Department.CreateInvoice"/> checks that the
23    /// creator may create it.
24    /// </summary>
25    /// <remarks>
26    /// The constructor sets secured members of the new invoice, including its owner. The caller of a constructor has
27    /// unlimited rights on the new object until its initialization ends (see <see cref="SecurableTypeAttribute"/>).
28    /// </remarks>
29    internal Invoice( Department department, Customer customer, string number, User? creator )
30    {
31        this.Department = department;
32        this.Customer = customer;
33        this.Number = number;
34        this.CreatorUser = creator;
35        this.Owner = creator;
36        this.DueDate = DateOnly.FromDateTime( DateTime.Today ).AddDays( 30 );
37    }
38
39    /// <summary>
40    /// Gets the invoice number. It is printed on every document and quoted by customers on the phone,
41    /// so it is excluded from default securing.
42    /// </summary>
43    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
44    public string Number { get; }
45
46    // The customer changes only through ChangeCustomer, which checks the new customer.
47    public Customer Customer { get; private set; } = null!;
48
49    /// <summary>
50    /// Gets the person who created the invoice. Unlike the owner, the creator never changes.
51    /// </summary>
52    public ISubject? Creator => this.CreatorUser;
53
54    public DateOnly DueDate { get; set; }
55
56    // The department changes only through TransferTo, which requires ChangeOwnership.
57    [RequiresPermission( PermissionNames.Read, PermissionNames.ChangeOwnership )]
58    public Department Department { get; private set; }
59
60    // The status, the payments and the void reason change only through the methods of the workflow, which grant
61    // ChangeStatus while they run.
62    [RequiresPermission( PermissionNames.Read, DemoPermissions.ChangeStatus )]
63    public InvoiceStatus Status { get; private set; }
64
65    public IList<InvoiceLine> Lines { get; } = new SecurableList<InvoiceLine>();
66
67    /// <summary>
68    /// Gets the internal notes of the invoice. Unlike the lines, notes can change at any status.
69    /// </summary>
70    public IList<string> Notes { get; } = new SecurableList<string>();
71
72    public decimal Total => this.Lines.Sum( l => l.Amount );
73
74    [RequiresPermission( PermissionNames.Read, DemoPermissions.ChangeStatus )]
75    public decimal AmountPaid { get; private set; }
76
77    [RequiresPermission( PermissionNames.Read, DemoPermissions.ChangeStatus )]
78    public string? VoidReason { get; private set; }
79
80    protected override IRoleBasedSecurable? SecurityParent => this.Department;
81
82    // Storage of the creator, mapped by Entity Framework.
83    private User? CreatorUser { get; set; }
84
85    protected override bool HasComputedRole( ISubject subject, Role role )
86        => role == DemoRoles.Creator && subject.Equals( this.CreatorUser );
87
88    /// <summary>
89    /// Bills another customer. The user must be able to edit the invoice and to read the new customer.
90    /// </summary>
91    public void ChangeCustomer( [RequiresPermission( PermissionNames.Read )] Customer customer )
92    {
93        this.EnsureStatus( InvoiceStatus.Draft );
94        this.Customer = customer;
95    }
96
97    [RequiresPermission( DemoPermissions.Approve )]
98    [GrantsPermissions( DemoPermissions.ChangeStatus )]
99    public void Approve()
100    {
101        this.EnsureStatus( InvoiceStatus.Draft );
102
103        if ( this.Lines.Count == 0 )
104        {
105            throw new InvalidOperationException( $"Invoice {this.Number} has no line." );
106        }
107
108        this.Status = InvoiceStatus.Approved;
109    }
110
111    /// <summary>
112    /// Sends the approved invoice to the customer through <paramref name="sender"/>.
113    /// </summary>
114    [RequiresPermission( DemoPermissions.Send )]
115    [GrantsPermissions( DemoPermissions.ChangeStatus )]
116    public async Task SendAsync( IInvoiceSender sender, CancellationToken cancellationToken = default )
117    {
118        this.EnsureStatus( InvoiceStatus.Approved );
119
120        await sender.SendAsync( this, cancellationToken );
121
122        this.Status = InvoiceStatus.Sent;
123    }
124
125    [RequiresPermission( DemoPermissions.RecordPayment )]
126    [GrantsPermissions( DemoPermissions.ChangeStatus )]
127    public void RecordPayment( decimal amount )
128    {
129        this.EnsureStatus( InvoiceStatus.Sent );
130
131        this.AmountPaid += amount;
132
133        if ( this.AmountPaid >= this.Total )
134        {
135            this.Status = InvoiceStatus.Paid;
136        }
137    }
138
139    [RequiresPermission( DemoPermissions.Void )]
140    [GrantsPermissions( DemoPermissions.ChangeStatus )]
141    public void Void( string reason )
142    {
143        if ( this.Status is InvoiceStatus.Paid or InvoiceStatus.Voided )
144        {
145            throw new InvalidOperationException( $"Invoice {this.Number} is {this.Status} and cannot be voided." );
146        }
147
148        this.Status = InvoiceStatus.Voided;
149        this.VoidReason = reason;
150    }
151
152    /// <summary>
153    /// Moves the invoice to another department. The user needs <c>ChangeOwnership</c> both on the invoice
154    /// and on the destination: otherwise anyone could push an invoice into a department where they have
155    /// more rights, or where it does not belong.
156    /// </summary>
157    [RequiresPermission( PermissionNames.ChangeOwnership )]
158    public void TransferTo( [RequiresPermission( PermissionNames.ChangeOwnership )] Department target )
159    {
160        this.Department = target;
161    }
162
163    // The securable lists call this method after they have checked the Write permission, before each change.
164    // The permission is already checked, so the method is excluded from default securing.
165    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
166    void ISecurableCollectionOwner.OnCollectionChanging( string propertyName, AccessKind accessKind )
167    {
168        if ( propertyName == nameof(this.Lines) )
169        {
170            this.EnsureStatus( InvoiceStatus.Draft );
171        }
172    }
173
174    public override string ToString() => $"invoice {this.Number}";
175
176    private void EnsureStatus( InvoiceStatus expected )
177    {
178        if ( this.Status != expected )
179        {
180            throw new InvalidOperationException( $"Invoice {this.Number} is {this.Status}, expected {expected}." );
181        }
182    }
183}
184
Transformed Code
1using Metalama.Framework.RunTime.Initialization;
2using Metalama.Samples.Authorization;
3using Metalama.Samples.Authorization.RoleBased;
4
5namespace Metalama.Samples.Authorization.Demo.Sales;
6
7/// <summary>
8/// An invoice issued by a <see cref="Department"/> to a <see cref="Customer"/>.
9/// </summary>
10/// <remarks>
11/// <para>Permission checks come from three sources: the <see cref="SecurableTypeAttribute"/> aspect
12/// inherited from <see cref="Entity"/> (properties need <c>Read</c>/<c>Write</c>), the project fabric (methods
13/// without an attribute need <c>Write</c>), and explicit <see cref="RequiresPermissionAttribute"/> attributes.</para>
14/// <para>Authorization answers "may this user do this?". Business rules, such as "an approved invoice cannot be
15/// edited", are a separate concern and are enforced by the methods themselves, and by
16/// <see cref="ISecurableCollectionOwner.OnCollectionChanging"/> for the lines.</para>
17/// </remarks>
18public sealed class Invoice : Entity, ISecurableCollectionOwner
19{
20    // Used by Entity Framework, which creates entities in a system scope, so the constructor can set secured properties.
21    private Invoice() : this( null!, null!, null!, null ) { }
22
23    /// <summary>
24    /// Creates a draft invoice, owned by its creator. <see cref="Demo.Department.CreateInvoice"/> checks that the
25    /// creator may create it.
26    /// </summary>
27    /// <remarks>
28    /// The constructor sets secured members of the new invoice, including its owner. The caller of a constructor has
29    /// unlimited rights on the new object until its initialization ends (see <see cref="SecurableTypeAttribute"/>).
30    /// </remarks>
31    internal Invoice( Department department, Customer customer, string number, User? creator ,InitializationContext context = default(InitializationContext))
32:base(context.Descend(InitializationSlot.OnConstructed)){SecurityContext.BeginInitialization(this);
33        this.Department = department;
34        this.Customer = customer;
35        this.Number = number;
36        this.CreatorUser = creator;
37        this.Owner = creator;
38        this.DueDate = DateOnly.FromDateTime( DateTime.Today ).AddDays( 30 );
39this.OnConstructed(context);
40    }
41    /// <summary>
42    /// Gets the invoice number. It is printed on every document and quoted by customers on the phone,
43    /// so it is excluded from default securing.
44    /// </summary>
45    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
46    public string Number { get; }
47private Customer _customer = null!;
48
49    // The customer changes only through ChangeCustomer, which checks the new customer.
50    public Customer Customer { get { AccessGuard.Demand(this, "Read", "Invoice.Customer", AccessKind.Read); return _customer; } private set { AccessGuard.Demand(this, "Write", "Invoice.Customer", AccessKind.Write); this._customer = value; } }
51    /// <summary>
52    /// Gets the person who created the invoice. Unlike the owner, the creator never changes.
53    /// </summary>
54    public ISubject? Creator {
55        get
56        {
57            AccessGuard.Demand(this, "Read", "Invoice.Creator", AccessKind.Read);
58            return this.CreatorUser;}
59    }
60    private DateOnly _dueDate;
61
62    public DateOnly DueDate { get { AccessGuard.Demand(this, "Read", "Invoice.DueDate", AccessKind.Read); return _dueDate; } set { AccessGuard.Demand(this, "Write", "Invoice.DueDate", AccessKind.Write); this._dueDate = value; } }
63    private Department _department = default!;
64
65    // The department changes only through TransferTo, which requires ChangeOwnership.
66    [RequiresPermission( PermissionNames.Read, PermissionNames.ChangeOwnership )]
67    public Department Department { get { AccessGuard.Demand(this, "Read", "Invoice.Department", AccessKind.Read); return _department; } private set { AccessGuard.Demand(this, "ChangeOwnership", "Invoice.Department", AccessKind.Write); this._department = value; } }
68    private InvoiceStatus _status;
69
70    // The status, the payments and the void reason change only through the methods of the workflow, which grant
71    // ChangeStatus while they run.
72    [RequiresPermission( PermissionNames.Read, DemoPermissions.ChangeStatus )]
73    public InvoiceStatus Status { get { AccessGuard.Demand(this, "Read", "Invoice.Status", AccessKind.Read); return _status; } private set { AccessGuard.Demand(this, "ChangeStatus", "Invoice.Status", AccessKind.Write); this._status = value; } }
74    private readonly IList<InvoiceLine> _lines = new SecurableList<InvoiceLine>(context: InitializationContext.WillInitialize).WithInitialize();
75
76    public IList<InvoiceLine> Lines { get { AccessGuard.Demand(this, "Read", "Invoice.Lines", AccessKind.Read); return SecurableCollection.SetOwner(_lines, this, "Write", "Invoice.Lines", "Lines"); } private init { this._lines = value; } } private readonly IList<string> _notes = new SecurableList<string>(context: InitializationContext.WillInitialize).WithInitialize();
77
78    /// <summary>
79    /// Gets the internal notes of the invoice. Unlike the lines, notes can change at any status.
80    /// </summary>
81    public IList<string> Notes { get { AccessGuard.Demand(this, "Read", "Invoice.Notes", AccessKind.Read); return SecurableCollection.SetOwner(_notes, this, "Write", "Invoice.Notes", "Notes"); } private init { this._notes = value; } }
82    public decimal Total {
83        get
84        {
85            AccessGuard.Demand(this, "Read", "Invoice.Total", AccessKind.Read);
86            return this.Lines.Sum( l => l.Amount );}
87    }
88    private decimal _amountPaid;
89
90    [RequiresPermission( PermissionNames.Read, DemoPermissions.ChangeStatus )]
91    public decimal AmountPaid { get { AccessGuard.Demand(this, "Read", "Invoice.AmountPaid", AccessKind.Read); return _amountPaid; } private set { AccessGuard.Demand(this, "ChangeStatus", "Invoice.AmountPaid", AccessKind.Write); this._amountPaid = value; } }
92    private string? _voidReason;
93
94    [RequiresPermission( PermissionNames.Read, DemoPermissions.ChangeStatus )]
95    public string? VoidReason { get { AccessGuard.Demand(this, "Read", "Invoice.VoidReason", AccessKind.Read); return _voidReason; } private set { AccessGuard.Demand(this, "ChangeStatus", "Invoice.VoidReason", AccessKind.Write); this._voidReason = value; } }
96
97    protected override IRoleBasedSecurable? SecurityParent => this.Department;
98private User? _creatorUser;
99
100    // Storage of the creator, mapped by Entity Framework.
101    private User? CreatorUser { get { AccessGuard.Demand(this, "Read", "Invoice.CreatorUser", AccessKind.Read); return _creatorUser; } set { AccessGuard.Demand(this, "Write", "Invoice.CreatorUser", AccessKind.Write); this._creatorUser = value; } }
102
103    protected override bool HasComputedRole( ISubject subject, Role role )
104        => role == DemoRoles.Creator && subject.Equals( this.CreatorUser );
105
106    /// <summary>
107    /// Bills another customer. The user must be able to edit the invoice and to read the new customer.
108    /// </summary>
109    public void ChangeCustomer( [RequiresPermission( PermissionNames.Read )] Customer customer )
110    {ISecurable? argument = customer;
111        if (argument != null)
112        {
113            AccessGuard.Demand(argument, "Read", "Invoice.ChangeCustomer(customer)", AccessKind.Execute);
114        }
115
116        AccessGuard.Demand(this, "Write", "Invoice.ChangeCustomer", AccessKind.Execute);
117        this.EnsureStatus(InvoiceStatus.Draft);
118        this.Customer = customer;
119        return;
120    }
121    [RequiresPermission( DemoPermissions.Approve )]
122    [GrantsPermissions( DemoPermissions.ChangeStatus )]
123    public void Approve()
124    {AccessGuard.Demand(this, "Approve", "Invoice.Approve", AccessKind.Execute);
125        using (SecurityContext.Grant(this, new string[] { "ChangeStatus" }))
126        {
127            this.EnsureStatus( InvoiceStatus.Draft );
128
129        if ( this.Lines.Count == 0 )
130        {
131            throw new InvalidOperationException( $"Invoice {this.Number} has no line." );
132        }
133
134        this.Status = InvoiceStatus.Approved;
135}
136
137        return;
138    }
139    /// <summary>
140    /// Sends the approved invoice to the customer through <paramref name="sender"/>.
141    /// </summary>
142    [RequiresPermission( DemoPermissions.Send )]
143    [GrantsPermissions( DemoPermissions.ChangeStatus )]
144    public async Task SendAsync( IInvoiceSender sender, CancellationToken cancellationToken = default )
145    {AccessGuard.Demand(this, "Send", "Invoice.SendAsync", AccessKind.Execute);
146        using (SecurityContext.Grant(this, new string[] { "ChangeStatus" }))
147        {
148            this.EnsureStatus( InvoiceStatus.Approved );
149
150        await sender.SendAsync( this, cancellationToken );
151
152        this.Status = InvoiceStatus.Sent;
153}
154
155        return;
156    }
157    [RequiresPermission( DemoPermissions.RecordPayment )]
158    [GrantsPermissions( DemoPermissions.ChangeStatus )]
159    public void RecordPayment( decimal amount )
160    {AccessGuard.Demand(this, "RecordPayment", "Invoice.RecordPayment", AccessKind.Execute);
161        using (SecurityContext.Grant(this, new string[] { "ChangeStatus" }))
162        {
163            this.EnsureStatus( InvoiceStatus.Sent );
164
165        this.AmountPaid += amount;
166
167        if ( this.AmountPaid >= this.Total )
168        {
169            this.Status = InvoiceStatus.Paid;
170        }
171}
172
173        return;
174    }
175    [RequiresPermission( DemoPermissions.Void )]
176    [GrantsPermissions( DemoPermissions.ChangeStatus )]
177    public void Void( string reason )
178    {AccessGuard.Demand(this, "Void", "Invoice.Void", AccessKind.Execute);
179        using (SecurityContext.Grant(this, new string[] { "ChangeStatus" }))
180        {
181            if ( this.Status is InvoiceStatus.Paid or InvoiceStatus.Voided )
182        {
183            throw new InvalidOperationException( $"Invoice {this.Number} is {this.Status} and cannot be voided." );
184        }
185
186        this.Status = InvoiceStatus.Voided;
187        this.VoidReason = reason;
188}
189
190        return;
191    }
192    /// <summary>
193    /// Moves the invoice to another department. The user needs <c>ChangeOwnership</c> both on the invoice
194    /// and on the destination: otherwise anyone could push an invoice into a department where they have
195    /// more rights, or where it does not belong.
196    /// </summary>
197    [RequiresPermission( PermissionNames.ChangeOwnership )]
198    public void TransferTo( [RequiresPermission( PermissionNames.ChangeOwnership )] Department target )
199    {ISecurable? argument = target;
200        if (argument != null)
201        {
202            AccessGuard.Demand(argument, "ChangeOwnership", "Invoice.TransferTo(target)", AccessKind.Execute);
203        }
204
205        AccessGuard.Demand(this, "ChangeOwnership", "Invoice.TransferTo", AccessKind.Execute);
206        this.Department = target;
207        return;
208    }
209    // The securable lists call this method after they have checked the Write permission, before each change.
210    // The permission is already checked, so the method is excluded from default securing.
211    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
212    void ISecurableCollectionOwner.OnCollectionChanging( string propertyName, AccessKind accessKind )
213    {
214        if ( propertyName == nameof(this.Lines) )
215        {
216            this.EnsureStatus( InvoiceStatus.Draft );
217        }
218    }
219
220    public override string ToString() => $"invoice {this.Number}";
221
222    private void EnsureStatus( InvoiceStatus expected )
223    {
224        if ( this.Status != expected )
225        {
226            throw new InvalidOperationException( $"Invoice {this.Number} is {this.Status}, expected {expected}." );
227        }
228    }
229public override void Initialize(InitializationContext context = default)
230    {
231        base.Initialize(context);
232        SecurityContext.EndInitialization(this);
233    }
234
235    protected override void OnConstructed(InitializationContext context = default)
236    {
237        base.OnConstructed(context);
238        SecurityContext.EndConstruction(this);
239    }
240}
241

The aspect implements IAspect<IMethod>, IAspect<IFieldOrProperty> and IAspect<IParameter>, so a single custom attribute can target the three kinds of declarations. Each BuildAspect method resolves the permission names and passes them to a template as compile-time arguments. The generated code calls AccessGuard.Demand with constant strings. There is no reflection and no allocation of permission objects at initialization.

1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Eligibility;
5
6namespace Metalama.Samples.Authorization;
7
8/// <summary>
9/// Aspect that requires the current subject to have a permission before a method executes,
10/// before a field or property is read or written, or on the argument passed to a parameter.
11/// </summary>
12/// <remarks>
13/// <para>On a method or a property, the permission is required on the current instance (<c>this</c>).
14/// On a parameter, it is required on the argument, which lets a method check the objects it receives.</para>
15/// <para>When the attribute does not name a permission, the defaults of <see cref="AuthorizationOptions"/> apply.</para>
16/// <para>The aspect is inherited: an override of a secured virtual member is secured too, so a derived class
17/// cannot skip the check by not calling the base implementation.</para>
18/// </remarks>
19[Inheritable]
20[AttributeUsage( AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter )]
21public sealed class RequiresPermissionAttribute : Aspect, IAspect<IMethod>, IAspect<IFieldOrProperty>, IAspect<IParameter>
22{
23    private readonly string? _permission;
24    private readonly string? _writePermission;
25
26    /// <summary>
27    /// Requires the permissions configured by <see cref="AuthorizationOptions"/>.
28    /// </summary>
29    public RequiresPermissionAttribute() { }
30
31    /// <summary>
32    /// Requires a permission to execute a method, to receive an argument, or to read and write a field or property.
33    /// </summary>
34    public RequiresPermissionAttribute( string permission )
35    {
36        this._permission = permission;
37    }
38
39    /// <summary>
40    /// Requires different permissions to read and to write a field or property.
41    /// </summary>
42    public RequiresPermissionAttribute( string readPermission, string writePermission )
43    {
44        this._permission = readPermission;
45        this._writePermission = writePermission;
46    }
47
48    public void BuildEligibility( IEligibilityBuilder<IMethod> builder )
49    {
50        builder.MustNotBeStatic();
51
52        // The permission is checked on the current instance, so it must be securable.
53        builder.DeclaringType().MustBeConvertibleTo( typeof(ISecurable) );
54
55        // An abstract member has no body, but the aspect can still be put on it to secure all overrides.
56        builder.ExceptForInheritance().MustNotBeAbstract();
57    }
58
59    public void BuildEligibility( IEligibilityBuilder<IFieldOrProperty> builder )
60    {
61        builder.MustNotBeStatic();
62        builder.DeclaringType().MustBeConvertibleTo( typeof(ISecurable) );
63        builder.ExceptForInheritance().MustNotBeAbstract();
64    }
65
66    public void BuildEligibility( IEligibilityBuilder<IParameter> builder )
67    {
68        builder.MustNotBeReturnParameter();
69        builder.MustBeReadable();
70
71        // On an abstract or interface method, the aspect is only inherited by the implementations.
72        builder.DeclaringMember().ExceptForInheritance().MustNotBeAbstract();
73
74        // Only a securable object can be checked: passing any other object would always be denied.
75        builder.Type().MustBeConvertibleTo( typeof(ISecurable) );
76    }
77
78    public void BuildAspect( IAspectBuilder<IMethod> builder )
79    {
80        var options = builder.Target.Enhancements().GetOptions<AuthorizationOptions>();
81        var permission = this._permission ?? options.DefaultExecutePermission ?? PermissionNames.Write;
82
83        builder.Override(
84            nameof(this.OverrideMethod),
85            args: new { permission, member = GetMemberName( builder.Target ) } );
86    }
87
88    public void BuildAspect( IAspectBuilder<IFieldOrProperty> builder )
89    {
90        var target = builder.Target;
91        var options = target.Enhancements().GetOptions<AuthorizationOptions>();
92
93        // A single permission applies to both reading and writing.
94        var readPermission = this._permission ?? options.DefaultReadPermission ?? PermissionNames.Read;
95        var writePermission = this._writePermission ?? this._permission ?? options.DefaultWritePermission ?? PermissionNames.Write;
96
97        var secureGetter = target.GetMethod != null;
98
99        // Every setter is checked, including a private one: the permission of a method does not imply the permission
100        // to change the properties of the object. A method that changes a property that its own permission does not
101        // cover grants the permission of the property with GrantsPermissions.
102        var secureSetter = target.Writeability is Writeability.All or Writeability.InitOnly
103                           && target.SetMethod != null;
104
105        if ( !secureGetter && !secureSetter )
106        {
107            builder.SkipAspect();
108
109            return;
110        }
111
112        var collectionKind = CollectionTypes.Classify( target.Type );
113
114        builder.OverrideAccessors(
115            secureGetter ? (GetterTemplateSelector) nameof(this.GetTemplate) : default,
116            secureSetter ? nameof(this.SetTemplate) : null,
117            args: new
118            {
119                readPermission,
120                writePermission,
121                member = GetMemberName( target ),
122
123                propertyName = target.Name,
124
125                // The getter sets the owner of the securable list that the property returns, so that each change of the
126                // list demands the write permission.
127                secureCollection = collectionKind == CollectionKind.Securable
128            } );
129    }
130
131    public void BuildAspect( IAspectBuilder<IParameter> builder )
132    {
133        var parameter = builder.Target;
134        var options = parameter.Enhancements().GetOptions<AuthorizationOptions>();
135        var permission = this._permission ?? options.DefaultReadPermission ?? PermissionNames.Read;
136        var member = $"{GetMemberName( (IMember) parameter.DeclaringMember! )}({parameter.Name})";
137
138        builder.AddContract( nameof(this.ValidateArgument), args: new { permission, member } );
139    }
140
141    private static string GetMemberName( IMember member )
142    {
143        if ( member is IConstructor )
144        {
145            return member.DeclaringType.Name;
146        }
147
148        // The name of an explicit interface implementation includes the namespace of the interface: keep only the
149        // interface name, for instance Document.IArchivable.Archive.
150        var name = member.Name;
151
152        if ( member.IsExplicitInterfaceImplementation )
153        {
154            var lastDot = name.LastIndexOf( '.' );
155            name = name.Substring( name.LastIndexOf( '.', lastDot - 1 ) + 1 );
156        }
157
158        return $"{member.DeclaringType.Name}.{name}";
159    }
160
161    [Template]
162    private dynamic? OverrideMethod( [CompileTime] string permission, [CompileTime] string member )
163    {
164        AccessGuard.Demand( meta.This, permission, member, AccessKind.Execute );
165
166        return meta.Proceed();
167    }
168
169    [Template]
170    private dynamic? GetTemplate(
171        [CompileTime] string readPermission,
172        [CompileTime] string writePermission,
173        [CompileTime] string member,
174        [CompileTime] string propertyName,
175        [CompileTime] bool secureCollection )
176    {
177        AccessGuard.Demand( meta.This, readPermission, member, AccessKind.Read );
178
179        if ( secureCollection )
180        {
181            return SecurableCollection.SetOwner( meta.Proceed(), meta.This, writePermission, member, propertyName );
182        }
183
184        return meta.Proceed();
185    }
186
187    [Template]
188    private void SetTemplate(
189        dynamic? value,
190        [CompileTime] string readPermission,
191        [CompileTime] string writePermission,
192        [CompileTime] string member,
193        [CompileTime] string propertyName,
194        [CompileTime] bool secureCollection )
195    {
196        AccessGuard.Demand( meta.This, writePermission, member, AccessKind.Write );
197        meta.Proceed();
198    }
199
200    [Template]
201    private void ValidateArgument( dynamic? value, [CompileTime] string permission, [CompileTime] string member )
202    {
203        // A null argument has nothing to secure; nullability is a separate concern. Testing a copy
204        // instead of the parameter keeps the parameter's nullable state intact for the rest of the method.
205        ISecurable? argument = value;
206
207        if ( argument != null )
208        {
209            AccessGuard.Demand( argument, permission, member, AccessKind.Execute );
210        }
211    }
212}
213

A few details are worth noting:

  • For a parameter, the aspect adds a contract with AddContract. Only parameters that have the attribute get a check.
  • Every setter is checked, including a private one: the members of the class need the permission to change the state of the object. A property that only some operations change declares the permission of these operations as its write permission. For instance, Customer.Department requires ChangeOwnership, the permission of Customer.MoveTo.
  • The getter of a collection property sets the owner of the SecurableList<T> that the property returns (see Collections).
  • The aspect is [Inheritable]. An override of a secured virtual member gets the same check, so a derived class cannot skip it by not calling the base implementation. On an abstract member, the aspect secures all the overrides.
  • The eligibility rules reject static members, members of a type that is not securable, out parameters, and parameters whose type is not securable. A method or property check is made on the current instance, so its type must implement ISecurable, and a static member has no instance at all. A parameter check needs a securable argument: passing an object that no policy can secure would always be denied, so it is a build error. Abstract members are rejected except through inheritance, with builder.ExceptForInheritance().MustNotBeAbstract().
  • When the attribute does not name a permission, the defaults of AuthorizationOptions apply. A method requires Write by default.

Permissions are not transitive

The permission of a member is checked when the member is entered, and only then. It does not grant the permissions of the members that the member accesses: when a method reads or writes a secured field or property, of the same object or of another one, this access is checked on its own, for the current subject. For instance, a method that requires Publish and changes the Body property of the same object also requires Read and Write on the object, because it reads and writes Body.

This rule follows from the design: each check protects one member, whatever the code that calls it. A member that granted more rights to the code that it runs would turn every bug in its body into a way around the policy.

The rule has a consequence for aggregates. It is common that a user may see an aggregate, such as a total or a count, but not the details that it is computed from. An aggregate computed from secured members requires the permissions of these members. Private state is not an exception: every field and every property is secured, whatever its accessibility, so moving the details to a private field does not remove their check. The unit tests in NestedAccessTests verify both cases. The next aspect covers aggregates explicitly.

GrantsPermissions

The [GrantsPermissions] aspect grants permissions while a member runs. On a method or a property, it grants them on the current instance. On a parameter, it grants them on the argument. In the demo, a department assistant may see whether the budget of their department is available, low or exhausted, but not its amounts. The Status property requires Read, and computes the level from Amount and Remaining, which require ReadSensitive:

[RequiresPermission( PermissionNames.Read )]
[GrantsPermissions( DemoPermissions.ReadSensitive )]
public BudgetStatus Status
    => this.Remaining <= 0 ? BudgetStatus.Exhausted
        : this.Remaining < this.Amount / 10 ? BudgetStatus.Low
        : BudgetStatus.Available;

The generated code checks the permission of the member, then opens the grant:

Source Code



1namespace Metalama.Samples.Authorization.Tests.GrantsPermissions;
2
3public class Folder : ISecurable
4{
5    [RequiresPermission( "ReadSensitive" )]


6    public string? Owner { get; set; }











7}

8
9public class Report : ISecurable
10{
























11    [RequiresPermission( "Read", "Write" )]


12    public string? Body { get; set; }






13







14    // The check of the member runs first, outside of the grant, so the grant cannot satisfy it.
15    [RequiresPermission( "Summarize" )]
16    [GrantsPermissions( "Read" )]
17    public int BodyLength => this.Body?.Length ?? 0;










18
19    // The check of the parameter also runs before the grant.
20    [RequiresPermission( "Publish" )]
21    [GrantsPermissions( "Read", "Write" )]
22    public void Publish( [RequiresPermission( "Read" )] Folder folder ) => this.Body += " (published)";













23
24    // On a parameter, the grant is on the argument, after the check of the argument.
25    [RequiresPermission( "Publish" )]
26    public bool IsOwnedBySomeone( [RequiresPermission( "Read" )] [GrantsPermissions( "ReadSensitive" )] Folder folder ) => folder.Owner != null;













27
28    // The aspect is inherited: the overrides grant the same permissions.
29    [RequiresPermission( "Summarize" )]
30    [GrantsPermissions( "Read" )]
31    public virtual int Score => this.Body?.Length ?? 0;










32
33    // In an async method, the grant covers the whole execution, including the continuations.
34    [RequiresPermission( "Publish" )]
35    [GrantsPermissions( "Read" )]
36    public async Task<int> CountWordsAsync( CancellationToken cancellationToken )
37    {
























38        await Task.Delay( 1, cancellationToken );

39
40        return this.Body?.Split( ' ' ).Length ?? 0;


41    }
42}
43
44public class WeightedReport : Report
45{


46    public override int Score => 2 * (this.Body?.Length ?? 0);

































47}
48
49// The check can also come from the default securing of SecurableType.
50public class Summary : ISecurable
51{


52    public string? Text { get; set; }


























53
54    [GrantsPermissions( "Read" )]
55    public int TextLength => this.Text?.Length ?? 0;




















56}
57
Transformed Code
1using Metalama.Framework.RunTime;
2using Metalama.Framework.RunTime.Initialization;
3
4namespace Metalama.Samples.Authorization.Tests.GrantsPermissions;
5
6public class Folder : ISecurable, IInitializable
7{
8    private string? _owner;
9
10    [RequiresPermission("ReadSensitive")]
11    public string? Owner
12    {
13        get
14        {
15            AccessGuard.Demand(this, "ReadSensitive", "Folder.Owner", AccessKind.Read);
16            return _owner;
17        }
18
19        set
20        {
21            AccessGuard.Demand(this, "ReadSensitive", "Folder.Owner", AccessKind.Write);
22            _owner = value;
23        }
24    }
25
26    public Folder([AspectGenerated] InitializationContext context)
27    {
28        SecurityContext.BeginInitialization(this);
29        if (!context.IsHandled(InitializationSlot.OnConstructed))
30        {
31            this.OnConstructed(context);
32        }
33    }
34
35    public Folder() : this(context: default)
36    {
37    }
38
39    public virtual void Initialize(InitializationContext context = default)
40    {
41        SecurityContext.EndInitialization(this);
42    }
43
44    protected virtual void OnConstructed(InitializationContext context = default)
45    {
46        SecurityContext.EndConstruction(this);
47    }
48}
49
50public class Report : ISecurable, IInitializable
51{
52    private string? _body;
53
54    [RequiresPermission("Read", "Write")]
55    public string? Body
56    {
57        get
58        {
59            AccessGuard.Demand(this, "Read", "Report.Body", AccessKind.Read);
60            return _body;
61        }
62
63        set
64        {
65            AccessGuard.Demand(this, "Write", "Report.Body", AccessKind.Write);
66            _body = value;
67        }
68    }
69
70    // The check of the member runs first, outside of the grant, so the grant cannot satisfy it.
71    [RequiresPermission("Summarize")]
72    [GrantsPermissions("Read")]
73    public int BodyLength
74    {
75        get
76        {
77            AccessGuard.Demand(this, "Summarize", "Report.BodyLength", AccessKind.Read);
78            using (SecurityContext.Grant(this, new string[] { "Read" }))
79            {
80                return this.Body?.Length ?? 0;
81            }
82        }
83    }
84
85    // The check of the parameter also runs before the grant.
86    [RequiresPermission("Publish")]
87    [GrantsPermissions("Read", "Write")]
88    public void Publish([RequiresPermission("Read")] Folder folder)
89    {
90        ISecurable? argument = folder;
91        if (argument != null)
92        {
93            AccessGuard.Demand(argument, "Read", "Report.Publish(folder)", AccessKind.Execute);
94        }
95
96        AccessGuard.Demand(this, "Publish", "Report.Publish", AccessKind.Execute);
97        using (SecurityContext.Grant(this, new string[] { "Read", "Write" }))
98        {
99            this.Body += " (published)";
100        }
101    }
102
103    // On a parameter, the grant is on the argument, after the check of the argument.
104    [RequiresPermission("Publish")]
105    public bool IsOwnedBySomeone([RequiresPermission("Read")][GrantsPermissions("ReadSensitive")] Folder folder)
106    {
107        ISecurable? argument = folder;
108        if (argument != null)
109        {
110            AccessGuard.Demand(argument, "Read", "Report.IsOwnedBySomeone(folder)", AccessKind.Execute);
111        }
112
113        AccessGuard.Demand(this, "Publish", "Report.IsOwnedBySomeone", AccessKind.Execute);
114        using (SecurityContext.Grant(folder, new string[] { "ReadSensitive" }))
115        {
116            return folder.Owner != null;
117        }
118    }
119
120    // The aspect is inherited: the overrides grant the same permissions.
121    [RequiresPermission("Summarize")]
122    [GrantsPermissions("Read")]
123    public virtual int Score
124    {
125        get
126        {
127            AccessGuard.Demand(this, "Summarize", "Report.Score", AccessKind.Read);
128            using (SecurityContext.Grant(this, new string[] { "Read" }))
129            {
130                return this.Body?.Length ?? 0;
131            }
132        }
133    }
134
135    // In an async method, the grant covers the whole execution, including the continuations.
136    [RequiresPermission("Publish")]
137    [GrantsPermissions("Read")]
138    public async Task<int> CountWordsAsync(CancellationToken cancellationToken)
139    {
140        AccessGuard.Demand(this, "Publish", "Report.CountWordsAsync", AccessKind.Execute);
141        using (SecurityContext.Grant(this, new string[] { "Read" }))
142        {
143            await Task.Delay(1, cancellationToken);
144
145            return this.Body?.Split(' ').Length ?? 0;
146        }
147    }
148
149    public Report([AspectGenerated] InitializationContext context)
150    {
151        SecurityContext.BeginInitialization(this);
152        if (!context.IsHandled(InitializationSlot.OnConstructed))
153        {
154            this.OnConstructed(context);
155        }
156    }
157
158    public Report() : this(context: default)
159    {
160    }
161
162    public virtual void Initialize(InitializationContext context = default)
163    {
164        SecurityContext.EndInitialization(this);
165    }
166
167    protected virtual void OnConstructed(InitializationContext context = default)
168    {
169        SecurityContext.EndConstruction(this);
170    }
171}
172
173public class WeightedReport : Report
174{
175    public override int Score
176    {
177        get
178        {
179            AccessGuard.Demand(this, "Summarize", "WeightedReport.Score", AccessKind.Read);
180            using (SecurityContext.Grant(this, new string[] { "Read" }))
181            {
182                return 2 * (this.Body?.Length ?? 0);
183            }
184        }
185    }
186
187    public WeightedReport([AspectGenerated] InitializationContext context) : base(context.Descend(InitializationSlot.OnConstructed))
188    {
189        SecurityContext.BeginInitialization(this);
190        if (!context.IsHandled(InitializationSlot.OnConstructed))
191        {
192            this.OnConstructed(context);
193        }
194    }
195
196    public WeightedReport() : this(context: default)
197    {
198    }
199
200    public override void Initialize(InitializationContext context = default)
201    {
202        base.Initialize(context);
203        SecurityContext.EndInitialization(this);
204    }
205
206    protected override void OnConstructed(InitializationContext context = default)
207    {
208        base.OnConstructed(context);
209        SecurityContext.EndConstruction(this);
210    }
211}
212
213// The check can also come from the default securing of SecurableType.
214public class Summary : ISecurable, IInitializable
215{
216    private string? _text;
217
218    public string? Text
219    {
220        get
221        {
222            AccessGuard.Demand(this, "Read", "Summary.Text", AccessKind.Read);
223            return _text;
224        }
225
226        set
227        {
228            AccessGuard.Demand(this, "Write", "Summary.Text", AccessKind.Write);
229            _text = value;
230        }
231    }
232
233    [GrantsPermissions("Read")]
234    public int TextLength
235    {
236        get
237        {
238            AccessGuard.Demand(this, "Read", "Summary.TextLength", AccessKind.Read);
239            using (SecurityContext.Grant(this, new string[] { "Read" }))
240            {
241                return this.Text?.Length ?? 0;
242            }
243        }
244    }
245
246    public Summary([AspectGenerated] InitializationContext context)
247    {
248        SecurityContext.BeginInitialization(this);
249        if (!context.IsHandled(InitializationSlot.OnConstructed))
250        {
251            this.OnConstructed(context);
252        }
253    }
254
255    public Summary() : this(context: default)
256    {
257    }
258
259    public virtual void Initialize(InitializationContext context = default)
260    {
261        SecurityContext.EndInitialization(this);
262    }
263
264    protected virtual void OnConstructed(InitializationContext context = default)
265    {
266        SecurityContext.EndConstruction(this);
267    }
268}
269

On a parameter, the grant gives the method access to an object that it receives. In the demo, IInvoiceSender.SendAsync requires Send on the invoice that it receives, so that no code can send an invoice for a user who may not send it, even without calling Invoice.SendAsync. Sending an invoice includes reading it, so the parameter also grants Read on the invoice. The two aspects are on the parameter of an interface method, and both are inherited by the parameter of every implementation, such as OutboxInvoiceSender.SendAsync:

Task SendAsync(
[RequiresPermission( DemoPermissions.Send )] [GrantsPermissions( PermissionNames.Read )] Invoice invoice,
CancellationToken cancellationToken );

Source Code



1namespace Metalama.Samples.Authorization.Tests.GrantsPermissions_Interface;
2
3public class Invoice : ISecurable
4{
5    [RequiresPermission( "Read" )]


6    public decimal Total { get; }










7}

8
























9// The aspects on the parameter of an interface method are inherited by the parameter of every implementation.
10public interface IInvoiceSender
11{
12    Task SendAsync( [RequiresPermission( "Send" )] [GrantsPermissions( "Read" )] Invoice invoice );
13}
14
15public class EmailInvoiceSender : IInvoiceSender
16{


17    public Task SendAsync( Invoice invoice )
18    {






19        _ = invoice.Total;
20
21        return Task.CompletedTask;

22    }
23}
24
Transformed Code
1using Metalama.Framework.RunTime;
2using Metalama.Framework.RunTime.Initialization;
3
4namespace Metalama.Samples.Authorization.Tests.GrantsPermissions_Interface;
5
6public class Invoice : ISecurable, IInitializable
7{
8    private readonly decimal _total;
9
10    [RequiresPermission("Read")]
11    public decimal Total
12    {
13        get
14        {
15            AccessGuard.Demand(this, "Read", "Invoice.Total", AccessKind.Read);
16            return _total;
17        }
18
19        private init
20        {
21            _total = value;
22        }
23    }
24
25    public Invoice([AspectGenerated] InitializationContext context)
26    {
27        SecurityContext.BeginInitialization(this);
28        if (!context.IsHandled(InitializationSlot.OnConstructed))
29        {
30            this.OnConstructed(context);
31        }
32    }
33
34    public Invoice() : this(context: default)
35    {
36    }
37
38    public virtual void Initialize(InitializationContext context = default)
39    {
40        SecurityContext.EndInitialization(this);
41    }
42
43    protected virtual void OnConstructed(InitializationContext context = default)
44    {
45        SecurityContext.EndConstruction(this);
46    }
47}
48
49// The aspects on the parameter of an interface method are inherited by the parameter of every implementation.
50public interface IInvoiceSender
51{
52    Task SendAsync([RequiresPermission("Send")][GrantsPermissions("Read")] Invoice invoice);
53}
54
55public class EmailInvoiceSender : IInvoiceSender
56{
57    public Task SendAsync(Invoice invoice)
58    {
59        ISecurable? argument = invoice;
60        if (argument != null)
61        {
62            AccessGuard.Demand(argument, "Send", "EmailInvoiceSender.SendAsync(invoice)", AccessKind.Execute);
63        }
64
65        using (SecurityContext.Grant(invoice, new string[] { "Read" }))
66        {
67            _ = invoice.Total;
68
69            return Task.CompletedTask;
70        }
71    }
72}
73

Department.CreateInvoice requires CreateInvoice on the department, and increments the invoice counter of the department. The counter declares CreateInvoice as its write permission, which the method has already checked. The method grants Read on the department, so the numbering of the invoices does not depend on a rule of the policy. An earlier version of the demo opened a system scope with SecurityContext.RunAsSystem in CreateInvoice. A system scope disables every check, on every object, including the checks of the code that the method calls. The grant gives only the permission that the code needs, and only on the department. The constructor of the new invoice needs no grant (see Constructing an object).

A method can also grant a permission that no role has. The status, the payments and the void reason of an invoice require ChangeStatus to change, and no rule of the policy gives ChangeStatus to a role. The methods of the workflow, Approve, SendAsync, RecordPayment and Void, grant it while they run, after their own permission has been checked. Changing the status is then a consequence of an operation of the workflow, and no other code can do it, even with the Write permission:

[RequiresPermission( PermissionNames.Read, DemoPermissions.ChangeStatus )]
public InvoiceStatus Status { get; private set; }

[RequiresPermission( DemoPermissions.Approve )]
[GrantsPermissions( DemoPermissions.ChangeStatus )]
public void Approve()

The grant is a scope of the ambient security context. SecurityContext.Grant starts a new execution context, whose grants are an immutable dictionary (ImmutableDictionary<ISecurable, ImmutableList<Permission>>) that contains the outer grants and the new one. Disposing the scope restores the previous execution context. AccessGuard.Demand looks for a grant of the permission on the same object before it calls the policy. A grant also starts a new cache of decisions (see Caching the decisions). The grants are not members of ISecurityContext: an application implements this interface to provide the subject and the policy, and it should not have to implement grants.

The aspect is designed to give the smallest possible grant:

  • The order of the aspects is critical. [RequiresPermission] is the outer aspect and [GrantsPermissions] is the inner one. The grant starts only after the permission of the member has been checked, so the grant cannot satisfy this permission. The parameter checks also run before the grant. The order is declared with [assembly: AspectOrder]. The same declaration orders [SecurableType], which must be processed before [RequiresPermission] because it adds [RequiresPermission] aspects.
  • The member must have a permission check, explicit or added by default. Otherwise any caller would get the granted permissions, so the validator reports the error AUTH004.
  • The grant applies to one object only: the current instance, or the argument of the parameter. A member that reads the details of another object needs the permissions of the subject on that object. A null argument gets no grant.
  • The grant applies only in the current context. When code switches to another subject with SecurityContext.Use, the grants of the previous subject do not apply.
  • The grant ends when the member returns. In an async member, it covers the continuations. An iterator is not eligible, because its body runs when the caller enumerates the result, after the member has returned. An automatic property is not eligible either, because it has no code that could use the grant.
  • The aspect is inherited, like [RequiresPermission]. An override or an implementation keeps the grant of the base member, so a derived class has the same behavior as its base class. On an abstract or interface member, the aspect is only inherited. A reviewer must therefore read the overrides of a member that grants permissions, and not only the member itself.

A grant takes precedence over the policy, including its denials. This is the purpose of the aspect, and also its risk: the body of the member must not return the details that the grant makes readable. Like an explicit exclusion from default securing, each grant is visible in the code and in a review. A grant also applies to the tasks that the member starts, because AsyncLocal<T> values flow into them. A member with a grant should therefore not start a task that runs after the member returns.

1using Metalama.Extensions.Validation;
2using Metalama.Framework.Advising;
3using Metalama.Framework.Aspects;
4using Metalama.Framework.Code;
5using Metalama.Framework.Diagnostics;
6using Metalama.Framework.Eligibility;
7
8namespace Metalama.Samples.Authorization;
9
10/// <summary>
11/// Aspect that grants permissions while a method or a property accessor runs, after the permission check of the
12/// member has passed. On a method or a property, the permissions are granted on the current instance (<c>this</c>).
13/// On a parameter, they are granted on the argument. Use it for an aggregate that the subject may see although it is
14/// computed from details that the subject may not see, for instance a total computed from secured lines.
15/// </summary>
16/// <remarks>
17/// <para>The member or the parameter must have a permission check (<see cref="RequiresPermissionAttribute"/>, explicit
18/// or added by default): otherwise any caller would get the granted permissions. A validator reports an error when it
19/// has none.</para>
20/// <para>The grant runs inside the check: the aspect order makes <see cref="RequiresPermissionAttribute"/> the outer
21/// aspect, so the grant cannot satisfy the check of its own member. The permission checks of the parameters also run
22/// before the grant.</para>
23/// <para>The grant applies to one object only, and only until the member returns. Accesses to other objects are
24/// checked as usual. It applies to the code that the member calls, and to the tasks that the member starts, because
25/// the grant flows with the logical call. It takes precedence over the policy, including its denials.</para>
26/// <para>The aspect is inherited, like <see cref="RequiresPermissionAttribute"/>: an override of a member that grants
27/// permissions grants them too, so that a derived class keeps the behavior of the base member.</para>
28/// </remarks>
29[Inheritable]
30[AttributeUsage( AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter )]
31public sealed class GrantsPermissionsAttribute : Aspect, IAspect<IMethod>, IAspect<IProperty>, IAspect<IParameter>
32{
33    private static readonly DiagnosticDefinition<IDeclaration> _uncheckedMemberError = new(
34        "AUTH004",
35        Severity.Error,
36        "'{0}' grants permissions but has no permission check, so any caller would get them. " +
37        "Add [RequiresPermission], or remove [GrantsPermissions]." );
38
39    private readonly string[] _permissions;
40
41    /// <summary>
42    /// Grants permissions on the current instance, or on the argument of a parameter, while the member runs.
43    /// </summary>
44    /// <param name="permissions">The names of the granted permissions.</param>
45    public GrantsPermissionsAttribute( params string[] permissions )
46    {
47        this._permissions = permissions;
48    }
49
50    public void BuildEligibility( IEligibilityBuilder<IMethod> builder )
51    {
52        builder.MustNotBeStatic();
53
54        // An abstract member has no body, but the aspect can still be put on it to grant in all overrides.
55        builder.ExceptForInheritance().MustNotBeAbstract();
56        builder.DeclaringType().MustBeConvertibleTo( typeof(ISecurable) );
57
58        // The body of an iterator runs when the result is enumerated, after the member has returned.
59        builder.MustSatisfy( method => method.GetIteratorInfo().IsIteratorMethod != true, method => $"{method} must not be an iterator" );
60    }
61
62    public void BuildEligibility( IEligibilityBuilder<IProperty> builder )
63    {
64        builder.MustNotBeStatic();
65        builder.ExceptForInheritance().MustNotBeAbstract();
66        builder.DeclaringType().MustBeConvertibleTo( typeof(ISecurable) );
67
68        // An automatic property has no code that could use the grant.
69        builder.MustSatisfy( property => property.IsAutoPropertyOrField != true, property => $"{property} must not be an automatic property" );
70    }
71
72    public void BuildEligibility( IEligibilityBuilder<IParameter> builder )
73    {
74        builder.MustNotBeReturnParameter();
75        builder.MustBeReadable();
76        builder.Type().MustBeConvertibleTo( typeof(ISecurable) );
77
78        // The grant wraps the body of the method.
79        builder.DeclaringMember().MustBeInstanceOfType( typeof(IMethod) );
80        builder.DeclaringMember().ExceptForInheritance().MustNotBeAbstract();
81        builder.DeclaringMember()
82            .MustSatisfy(
83                member => ((IMethod) member).GetIteratorInfo().IsIteratorMethod != true,
84                member => $"{member} must not be an iterator" );
85    }
86
87    public void BuildAspect( IAspectBuilder<IMethod> builder )
88    {
89        builder.Override( nameof(this.OverrideMethod), args: new { permissions = this._permissions } );
90        ValidateLater( builder );
91    }
92
93    public void BuildAspect( IAspectBuilder<IProperty> builder )
94    {
95        var property = builder.Target;
96
97        builder.OverrideAccessors(
98            property.GetMethod != null ? (GetterTemplateSelector) nameof(this.GetTemplate) : default,
99            property.SetMethod != null ? nameof(this.SetTemplate) : null,
100            args: new { permissions = this._permissions } );
101
102        ValidateLater( builder );
103    }
104
105    public void BuildAspect( IAspectBuilder<IParameter> builder )
106    {
107        var parameter = builder.Target;
108
109        builder.With( (IMethod) parameter.DeclaringMember! )
110            .Override( nameof(this.OverrideMethodForParameter), args: new { parameter, permissions = this._permissions } );
111
112        ValidateLater( builder );
113    }
114
115    // The check can be added by another aspect, for instance SecurableType, so the validator runs after all aspects.
116    private static void ValidateLater<T>( IAspectBuilder<T> builder )
117        where T : class, IDeclaration
118        => builder.Outbound.Validate(
119            context =>
120            {
121                var hasCheck = context.Declaration switch
122                {
123                    IMethod method => method.Enhancements().HasAspect<RequiresPermissionAttribute>(),
124                    IProperty property => property.Enhancements().HasAspect<RequiresPermissionAttribute>(),
125                    IParameter parameter => parameter.Enhancements().HasAspect<RequiresPermissionAttribute>(),
126                    _ => false
127                };
128
129                if ( !hasCheck )
130                {
131                    context.Diagnostics.Report( _uncheckedMemberError.WithArguments( context.Declaration ) );
132                }
133            },
134            new DeclarationValidationOptions { Time = DeclarationValidationTime.AfterAllAspects } );
135
136    [Template]
137    private dynamic? OverrideMethod( [CompileTime] string[] permissions )
138    {
139        using ( SecurityContext.Grant( meta.This, meta.RunTime( permissions ) ) )
140        {
141            return meta.Proceed();
142        }
143    }
144
145    [Template]
146    private dynamic? OverrideMethodForParameter( [CompileTime] IParameter parameter, [CompileTime] string[] permissions )
147    {
148        // A null argument has nothing to grant on: Grant accepts null and grants nothing.
149        using ( SecurityContext.Grant( parameter.Value, meta.RunTime( permissions ) ) )
150        {
151            return meta.Proceed();
152        }
153    }
154
155    [Template]
156    private dynamic? GetTemplate( [CompileTime] string[] permissions )
157    {
158        using ( SecurityContext.Grant( meta.This, meta.RunTime( permissions ) ) )
159        {
160            return meta.Proceed();
161        }
162    }
163
164    [Template]
165    private void SetTemplate( dynamic? value, [CompileTime] string[] permissions )
166    {
167        using ( SecurityContext.Grant( meta.This, meta.RunTime( permissions ) ) )
168        {
169            meta.Proceed();
170        }
171    }
172}
173

Constructing an object

The code that creates an object has unlimited rights on this object while it is initialized: from the first constructor that runs to the end of the object initializer. No other code can access the object before its construction ends, so setting it up is not an access to the data of somebody else. In the demo, the constructor of Invoice sets the department, the customer, the due date and the owner of the new invoice, and the creator needs neither Write nor ChangeOwnership:

internal Invoice( Department department, Customer customer, string number, User? creator )
{
    this.Department = department;
    this.Customer = customer;
    this.Number = number;
    this.CreatorUser = creator;
    this.Owner = creator;
    this.DueDate = DateOnly.FromDateTime( DateTime.Today ).AddDays( 30 );
}

An object goes through three states:

  1. While its constructors run, the code that creates the object, in its execution context, accesses the object without checks.
  2. After the last constructor, and until the end of the object initializer, the creator can only set the properties of the object, as an object initializer does. Its getters and its methods throw an InvalidOperationException.
  3. After the object initializer, the object is initialized and checked like any other object.

Any access from another execution context throws an InvalidOperationException until the object is initialized: an object that is not completely set up cannot be used by anybody else.

[SecurableType] adds three initializers to every securable class, one for each transition:

  • A BeforeInstanceConstructor initializer calls SecurityContext.BeginInitialization( this ) before the code of the constructors.
  • An AfterLastInstanceConstructor initializer calls SecurityContext.EndConstruction( this ) at the end of the last constructor of the chain, from the OnConstructed method that Metalama introduces.
  • An AfterObjectInitializer initializer calls SecurityContext.EndInitialization( this ) from the Initialize method that Metalama introduces. Metalama rewrites each new expression of a securable class to call Initialize after the object initializer, so new Folder { Owner = creator } is not checked either.

AccessGuard.Demand applies these rules before it calls the policy. An access that the creator makes during the initialization is not cached, and it is audited like a granted access. The accesses to other objects are checked as usual: a constructor that reads another object needs the permissions of the subject on that object.

The rights of the creator are recorded in the current execution context, not on the object:

  • A grant shares the objects being initialized with its parent, because it keeps the subject. SecurityContext.Use does not, because it changes the subject.
  • Nothing is recorded in a system scope, where nothing is checked anyway. Entity Framework creates the objects that it loads in a system scope, so loading an object does not open it.
  • Nothing is recorded without an execution context, so a constructor that sets secured members without a context fails closed.

Metalama calls Initialize only at the call sites that it compiles. An object created by reflection, by a serializer, or by an assembly that is not compiled with Metalama stays in the second state: its creator can set its properties, but nobody can read it or call its methods. The code that creates such an object calls ISecurable.CompleteInitialization, which calls Initialize. ISecurable.IsInitialized tells whether the initialization has ended. Both are default implementations of the interface, so a class does not implement them. The interface does not derive from IInitializable: every class that implements it directly would then have to implement Initialize in its source code, because Metalama introduces this method after the C# compiler has checked the source.

The AfterLastInstanceConstructor initializer adds an InitializationContext parameter to the constructors, which coordinates the initializers across the levels of a class hierarchy. A parameterless constructor is kept, and forwards to the constructor that has the parameter, so Activator.CreateInstance<T>() and the new() constraint still work. A constructor with parameters gets an optional InitializationContext parameter, so reflection code that passes arguments must pass this parameter too.

The introduced Initialize method is public, and OnConstructed is protected. [SecurableType] excludes both from default securing, because a check would fail for every creator who cannot write the object.

Note

Metalama rewrites a new expression of a securable class even inside an expression tree, where the rewritten call does not compile (error CS0854). The value converter of Invoice.Notes in InvoicingDbContext therefore creates its lists in static methods, and not in its lambda expressions.

ISecurable and SecurableType

ISecurable is an empty interface that marks the securable classes. The [SecurableType] aspect is applied to this interface, and Metalama propagates an [Inheritable] aspect from an interface to every class that implements it, and to their derived classes. IRoleBasedSecurable derives from ISecurable, so the demo's Entity, which derives from RoleBasedSecurable, has no attribute at all:

1using Metalama.Framework.RunTime.Initialization;
2
3namespace Metalama.Samples.Authorization;
4
5/// <summary>
6/// Marks the classes whose members are secured. Every class that implements this interface, directly or through
7/// a derived interface such as <see cref="RoleBased.IRoleBasedSecurable"/>, inherits the
8/// <see cref="SecurableTypeAttribute"/> aspect: its members get permission checks by default, and a validator
9/// verifies that none is left unsecured.
10/// </summary>
11/// <remarks>
12/// Being securable is part of the contract of the type, so it cannot be forgotten: an object that the policy can
13/// secure is always secured and validated. A parameter with <see cref="RequiresPermissionAttribute"/> must also be
14/// of a securable type, so that passing an object that no policy can secure is a build error.
15/// </remarks>
16[SecurableType]
17public interface ISecurable
18{
19    /// <summary>
20    /// Gets a value indicating whether the initialization of the object has ended. It is <c>false</c> from the first
21    /// constructor until the end of the object initializer, or until <see cref="CompleteInitialization"/> when Metalama
22    /// could not rewrite the call site. An object created in a system scope or without a security context is not
23    /// tracked, so it is always initialized.
24    /// </summary>
25    bool IsInitialized => SecurityContext.IsInitialized( this );
26
27    /// <summary>
28    /// Ends the initialization of an object that was created where Metalama could not rewrite the call site, for
29    /// instance by reflection or by a serializer. Until then, the getters and the methods of the object throw an
30    /// <see cref="InvalidOperationException"/>.
31    /// </summary>
32    /// <remarks>
33    /// A call site that Metalama compiles calls <see cref="IInitializable.Initialize"/> after the object initializer,
34    /// and does not need this method. The interface does not derive from <see cref="IInitializable"/>, because every
35    /// class that implements it directly would then have to implement <see cref="IInitializable.Initialize"/> in its
36    /// source code: Metalama introduces this method after the C# compiler has checked the source.
37    /// </remarks>
38    void CompleteInitialization()
39    {
40        if ( this is IInitializable initializable )
41        {
42            initializable.Initialize();
43        }
44        else
45        {
46            SecurityContext.EndInitialization( this );
47        }
48    }
49}
50

The [SecurableType] aspect adds [RequiresPermission] to all members of a class that do not have it yet, and registers a validator (see below). On the interface itself, the aspect does nothing: its eligibility rejects interfaces except for inheritance, with builder.ExceptForInheritance().MustNotBeInterface(). Its eligibility also rejects a class that does not implement ISecurable, because the checks are made on the current instance.

Attaching the aspect to the interface, rather than asking the developer to add an attribute to each class hierarchy, makes two facts the same: an object that the policy can secure is always secured and validated. With an attribute, a new hierarchy of securable classes that forgets the attribute has no default check, and the validator cannot report it, because the validator is part of the missing aspect. With the interface, the security of a class is part of its type, so it cannot be forgotten. The interface also gives the parameter checks a type: a [RequiresPermission] parameter must be securable, which the compiler verifies.

The cost is that securing becomes implicit: a class that implements the interface for another reason is secured too. A type or a member is excluded explicitly with [AuthorizationOptions( IsDefaultSecuringEnabled = false )]. The framework uses this for its own infrastructure members, such as RoleBasedSecurable.HasRole, which the policy calls, and for SecurableList<T> (see Collections). The framework unit tests disable default securing for their whole assembly, because they test explicit attributes.

1using Metalama.Extensions.Validation;
2using Metalama.Framework.Advising;
3using Metalama.Framework.Aspects;
4using Metalama.Framework.Code;
5using Metalama.Framework.Diagnostics;
6using Metalama.Framework.Eligibility;
7using Metalama.Samples.Authorization;
8
9// The order of execution at run time, from the outermost aspect to the innermost:
10// - RequiresPermission checks the permission of the member before anything else runs.
11// - GrantsPermissions grants permissions only after this check has passed, so the grant cannot satisfy it.
12// - SecurableType adds RequiresPermission aspects, so it must be processed first at build time, which is the
13//   reverse of the run-time order.
14[assembly: AspectOrder(
15    AspectOrderDirection.RunTime,
16    typeof(RequiresPermissionAttribute),
17    typeof(GrantsPermissionsAttribute),
18    typeof(SecurableTypeAttribute) )]
19
20namespace Metalama.Samples.Authorization;
21
22/// <summary>
23/// Aspect that makes a class securable: it adds <see cref="RequiresPermissionAttribute"/> with the default
24/// permissions of <see cref="AuthorizationOptions"/> to the fields, properties and methods of the class that do not
25/// have it yet, and validates that no member is left unsecured.
26/// </summary>
27/// <remarks>
28/// <para>The aspect is applied to the <see cref="ISecurable"/> interface and inherited by every class that implements
29/// it, and by their derived classes. It can also be applied directly to a class.</para>
30/// <para>The caller of a constructor has unlimited rights on the new object while it is initialized: the aspect
31/// starts the initialization before the code of the constructors, and ends it after the object initializer (see
32/// <see cref="SecurityContext.BeginInitialization"/>).</para>
33/// <para>The aspect secures by default: every field and property, every public method, and every explicit interface
34/// implementation, gets a check.
35/// A member is excluded only explicitly, with <c>[AuthorizationOptions( IsDefaultSecuringEnabled = false )]</c>.
36/// After all aspects have been applied, a validator reports a warning for every exposed member that still has no
37/// check and was not excluded explicitly, for instance a static member, an indexer or an event, which the aspects
38/// cannot secure.</para>
39/// </remarks>
40[Inheritable]
41[AttributeUsage( AttributeTargets.Class | AttributeTargets.Interface )]
42public sealed class SecurableTypeAttribute : TypeAspect
43{
44    private static readonly DiagnosticDefinition<(IDeclaration Member, INamedType Type)> _unsecuredMemberWarning = new(
45        "AUTH001",
46        Severity.Warning,
47        "'{0}' is exposed by the secured type '{1}' but has no permission check. " +
48        "Add [RequiresPermission], or exclude the member explicitly with [AuthorizationOptions( IsDefaultSecuringEnabled = false )]." );
49
50    private static readonly DiagnosticDefinition<(IDeclaration Member, INamedType Type)> _staticMemberWarning = new(
51        "AUTH002",
52        Severity.Warning,
53        "'{0}' is a static member of the secured type '{1}'. A static member has no instance on which to check a permission. " +
54        "Move it to another type, or exclude it explicitly with [AuthorizationOptions( IsDefaultSecuringEnabled = false )]." );
55
56    private static readonly DiagnosticDefinition<(IDeclaration Member, IType Type)> _mutableCollectionWarning = new(
57        "AUTH003",
58        Severity.Warning,
59        "'{0}' returns the collection type '{1}', which the getter cannot secure: a caller who can read it may change its " +
60        "content without the write permission. Declare it as IList<T>, ICollection<T>, IReadOnlyList<T>, " +
61        "IReadOnlyCollection<T> or IEnumerable<T>, initialized with a SecurableList<T>, or as an immutable type." );
62
63    // On an interface, the aspect does nothing itself: it is inherited by the classes that implement the interface.
64    // The aspect is useful only on a securable type: the checks are made on the current instance.
65    public override void BuildEligibility( IEligibilityBuilder<INamedType> builder )
66    {
67        builder.ExceptForInheritance().MustNotBeInterface();
68        builder.MustSatisfy( type => type.IsConvertibleTo( typeof(ISecurable) ), type => $"{type} must implement ISecurable" );
69    }
70
71    public override void BuildAspect( IAspectBuilder<INamedType> builder )
72    {
73        // The object is being initialized from the first constructor that runs until the end of the object
74        // initializer. The call sites of the constructors call Initialize after the object initializer.
75        builder.AddInitializer( nameof(this.BeginInitialization), InitializerKind.BeforeInstanceConstructor );
76        builder.AddInitializer( nameof(this.EndConstruction), InitializerKind.AfterLastInstanceConstructor );
77        builder.AddInitializer( nameof(this.EndInitialization), InitializerKind.AfterObjectInitializer );
78
79        builder.Outbound
80            .SelectMany(
81                type => type.FieldsAndProperties.Where(
82                    member => IsSecuredByDefault( member, member.Enhancements().GetOptions<AuthorizationOptions>() ) ) )
83            .AddAspectIfEligible( _ => new RequiresPermissionAttribute() );
84
85        builder.Outbound
86            .SelectMany(
87                type => type.Methods.Where(
88                    method => IsOrdinaryMethod( method )
89                              && !OverridesObjectMethod( method )
90                              && IsSecuredByDefault( method, method.Enhancements().GetOptions<AuthorizationOptions>() ) ) )
91            .AddAspectIfEligible( _ => new RequiresPermissionAttribute() );
92
93        // Verifies the final code, after all aspects, so that it sees the checks added by any aspect.
94        builder.Outbound
95            .SelectMany( type => type.Members().Where( IsValidated ) )
96            .Validate( ValidateMember, new DeclarationValidationOptions { Time = DeclarationValidationTime.AfterAllAspects } );
97    }
98
99    private static bool IsSecuredByDefault( IMember member, AuthorizationOptions options )
100        => !member.IsStatic
101           && !member.Attributes.Any( typeof(RequiresPermissionAttribute) ) // An explicit attribute wins over the defaults.
102           && IsExposed( member, options );
103
104    // Determines whether a member must have a check, unless it is excluded explicitly.
105    private static bool IsExposed( IMember member, AuthorizationOptions options )
106    {
107        if ( member.IsImplicitlyDeclared || options.IsDefaultSecuringEnabled == false || IsInitializationMethod( member ) )
108        {
109            return false;
110        }
111
112        // An explicit interface implementation is private, but any caller reaches it through the interface.
113        if ( member.IsExplicitInterfaceImplementation )
114        {
115            return true;
116        }
117
118        // The fields and properties hold the state of the object, so they are secured whatever their accessibility:
119        // a member of the class must not change or read the state without the permission. A private static member
120        // has no instance to secure, and is not exposed.
121        if ( member is IFieldOrProperty )
122        {
123            return !member.IsStatic || member.Accessibility != Accessibility.Private;
124        }
125
126        return member.Accessibility switch
127        {
128            Accessibility.Public => true,
129            Accessibility.Private => false,
130            _ => options.SecureNonPublicMembers == true
131        };
132    }
133
134    // The methods that Metalama introduces for the initializers, such as the Initialize method that the call sites of
135    // the constructors call. They end the initialization of the object, so a check would fail for every caller who
136    // cannot write the object.
137    private static bool IsInitializationMethod( IMember member )
138        => member is IMethod { Origin.Kind: DeclarationOriginKind.Aspect, Name: "Initialize" or "OnConstructed" };
139
140    private static bool IsValidated( IMember member )
141        => member switch
142        {
143            // An abstract member has no body to check; each override is validated instead.
144            { IsAbstract: true } => false,
145
146            IMethod method => IsOrdinaryMethod( method ) && !OverridesObjectMethod( method ),
147            IConstructor => false,
148            _ => true
149        };
150
151    private static void ValidateMember( DeclarationValidationContext context )
152    {
153        var member = (IMember) context.Declaration;
154
155        var options = member switch
156        {
157            IMethod method => method.Enhancements().GetOptions<AuthorizationOptions>(),
158            IFieldOrProperty fieldOrProperty => fieldOrProperty.Enhancements().GetOptions<AuthorizationOptions>(),
159            IIndexer indexer => indexer.Enhancements().GetOptions<AuthorizationOptions>(),
160            IEvent @event => @event.Enhancements().GetOptions<AuthorizationOptions>(),
161            _ => null
162        };
163
164        // The aspect supports methods, fields and properties. An indexer or an event never has a check.
165        var hasCheck = member switch
166        {
167            IMethod method => method.Enhancements().HasAspect<RequiresPermissionAttribute>(),
168            IFieldOrProperty fieldOrProperty => HasCheck( fieldOrProperty ),
169            _ => false
170        };
171
172        if ( options == null || !IsExposed( member, options ) )
173        {
174            return;
175        }
176
177        // A secured getter protects the reference, not the content of a mutable collection.
178        if ( member is IFieldOrProperty { Type: var type } && CollectionTypes.Classify( type ) == CollectionKind.Unsecurable )
179        {
180            context.Diagnostics.Report( _mutableCollectionWarning.WithArguments( (member, type) ) );
181        }
182
183        if ( !hasCheck )
184        {
185            var warning = member.IsStatic ? _staticMemberWarning : _unsecuredMemberWarning;
186            context.Diagnostics.Report( warning.WithArguments( (member, member.DeclaringType) ) );
187        }
188    }
189
190    // To secure a field, Metalama turns it into a property. The aspect is on the field, and the final code model
191    // shows the property, so both are checked.
192    private static bool HasCheck( IFieldOrProperty fieldOrProperty )
193        => fieldOrProperty.Enhancements().HasAspect<RequiresPermissionAttribute>()
194           || fieldOrProperty is IProperty { OriginalField: { } field } && field.Enhancements().HasAspect<RequiresPermissionAttribute>()
195           || fieldOrProperty is IField { OverridingProperty: { } property } && property.Enhancements().HasAspect<RequiresPermissionAttribute>();
196
197    [Template]
198    private void BeginInitialization() => SecurityContext.BeginInitialization( meta.This );
199
200    [Template]
201    private void EndConstruction() => SecurityContext.EndConstruction( meta.This );
202
203    [Template]
204    private void EndInitialization() => SecurityContext.EndInitialization( meta.This );
205
206    // Explicit interface implementations have their own method kind; accessors, operators and finalizers are excluded.
207    private static bool IsOrdinaryMethod( IMethod method )
208        => method.MethodKind is MethodKind.Default or MethodKind.ExplicitInterfaceImplementation;
209
210    // ToString, Equals and GetHashCode are called by infrastructure code, such as collections and loggers,
211    // so they are never secured by default.
212    private static bool OverridesObjectMethod( IMethod method )
213    {
214        for ( var baseMethod = method.OverriddenMethod; baseMethod != null; baseMethod = baseMethod.OverriddenMethod )
215        {
216            if ( baseMethod.DeclaringType.SpecialType == SpecialType.Object )
217            {
218                return true;
219            }
220        }
221
222        return false;
223    }
224}
225

The aspect adds child aspects with builder.Outbound.SelectMany(...).AddAspectIfEligible(...). A child aspect must be processed after its parent, so the library declares the order with [assembly: AspectOrder] (see GrantsPermissions).

The aspect secures by default. Every field and every property gets a check, whatever its accessibility, because they hold the state of the object: a member of the class must not read or change the state without the permission. Every public method gets a check too, and so does every explicit interface implementation: such a member is private in C#, but any caller reaches it through the interface. A reader who copies the aspect without any configuration therefore gets a secured class, not a class with unsecured methods. A member that must stay unsecured is excluded explicitly, with [AuthorizationOptions( IsDefaultSecuringEnabled = false )], which also documents the decision for a reviewer.

The aspect never secures private methods, members that already have [RequiresPermission], or overrides of ToString, Equals and GetHashCode. Loggers and collections call these methods. A private method runs with the permissions of its caller, and its accesses to the state are checked. A readonly field gets only a read check, because only a constructor can write it.

Verifying that every member is secured

Default securing has gaps by construction. The aspects cannot secure a static member, which has no instance on which to check a permission, nor an indexer or an event, which they do not support. A member can also lose its check by mistake, for instance after a change of options. In an authorization framework, such a gap is a security hole that nothing reports: the code compiles, the tests of the other members pass, and the member is open to every user.

For this reason, [SecurableType] also registers a validator. The validator runs after all aspects have been applied (DeclarationValidationTime.AfterAllAspects), so it sees the final code: the checks added by default, by explicit attributes and by inheritance. It reports a warning for each member that a caller can reach but that has no check and was not excluded explicitly:

  • AUTH001: an exposed member has no permission check, for instance an indexer or an event.
  • AUTH002: an exposed member is static.
  • AUTH003: an exposed field or property has a mutable collection type that the aspect cannot protect, such as List<T>, an array or a dictionary (see Collections).

The validator turns the rule "every member of a secured class is secured" from a convention into a build-time verification, at no cost at run time. Each warning names the member and gives the two possible fixes. An explicit exclusion is visible in the code and in a review, while an unintended gap is not.

Source Code
1using System.Collections.Immutable;


2
3namespace Metalama.Samples.Authorization.Tests.Validation_UnsecuredMembers;




4
5public class Document : ISecurable
6{








7    private readonly ImmutableArray<string> _pages = ["Cover"];




8
9    // Secured by default: no warning.
10    public string? Title { get; set; }






11







12    // A static member cannot be secured: warning AUTH002.
    Warning AUTH002: 'Document.Create()' is a static member of the secured type 'Document'. A static member has no instance on which to check a permission. Move it to another type, or exclude it explicitly with [AuthorizationOptions( IsDefaultSecuringEnabled = false )].

13    public static Document Create() => new();



14
15    // The aspect does not support indexers and events: warning AUTH001.
    Warning AUTH001: 'Document.this[int]' is exposed by the secured type 'Document' but has no permission check. Add [RequiresPermission], or exclude the member explicitly with [AuthorizationOptions( IsDefaultSecuringEnabled = false )].

16    public string this[ int index ] => this._pages[index];
17
    Warning AUTH001: 'Document.Changed' is exposed by the secured type 'Document' but has no permission check. Add [RequiresPermission], or exclude the member explicitly with [AuthorizationOptions( IsDefaultSecuringEnabled = false )].

18    public event EventHandler? Changed;
19
20    // Excluded explicitly: no warning.
21    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
22    public static int MaxPages => 100;
23
24    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
25    public event EventHandler? Viewed;























26}
27
Transformed Code
1using System.Collections.Immutable;
2using Metalama.Framework.RunTime;
3using Metalama.Framework.RunTime.Initialization;
4
5namespace Metalama.Samples.Authorization.Tests.Validation_UnsecuredMembers;
6
7public class Document : ISecurable, IInitializable
8{
9    private readonly ImmutableArray<string> _pages1 = ["Cover"];
10
11    private ImmutableArray<string> _pages
12    {
13        get
14        {
15            AccessGuard.Demand(this, "Read", "Document._pages", AccessKind.Read);
16            return _pages1;
17        }
18
19        init
20        {
21            _pages1 = value;
22        }
23    }
24
25    private string? _title;
26
27    // Secured by default: no warning.
28    public string? Title
29    {
30        get
31        {
32            AccessGuard.Demand(this, "Read", "Document.Title", AccessKind.Read);
33            return _title;
34        }
35
36        set
37        {
38            AccessGuard.Demand(this, "Write", "Document.Title", AccessKind.Write);
39            _title = value;
40        }
41    }
42
43    // A static member cannot be secured: warning AUTH002.
44    public static Document Create()
45    {
46        return InitializableExtensions.WithInitialize<Document>(new(context: InitializationContext.WillInitialize));
47    }
48
49    // The aspect does not support indexers and events: warning AUTH001.
50    public string this[int index] => this._pages[index];
51
52    public event EventHandler? Changed;
53
54    // Excluded explicitly: no warning.
55    [AuthorizationOptions(IsDefaultSecuringEnabled = false)]
56    public static int MaxPages => 100;
57
58    [AuthorizationOptions(IsDefaultSecuringEnabled = false)]
59    public event EventHandler? Viewed;
60
61    public Document([AspectGenerated] InitializationContext context)
62    {
63        SecurityContext.BeginInitialization(this);
64        if (!context.IsHandled(InitializationSlot.OnConstructed))
65        {
66            this.OnConstructed(context);
67        }
68    }
69
70    public Document() : this(context: default)
71    {
72    }
73
74    public virtual void Initialize(InitializationContext context = default)
75    {
76        SecurityContext.EndInitialization(this);
77    }
78
79    protected virtual void OnConstructed(InitializationContext context = default)
80    {
81        SecurityContext.EndConstruction(this);
82    }
83}
84

Two details of the validator come from the way Metalama transforms the code. To secure a field, Metalama turns it into a property, so the validator looks for the aspect on both the field and the property (IProperty.OriginalField). An abstract member has no body, so the validator checks its overrides instead.

Collections

A check on a getter protects the reference that it returns, not the content of the object behind it. If a property returned an ordinary list, a user who can only read the property could add or remove items, which bypasses the write permission. The developer should not have to think about it: a collection property is written as ordinary C# code, and the framework protects it.

public IList<InvoiceLine> Lines { get; } = new SecurableList<InvoiceLine>();

SecurableList<T> is the list itself, not a wrapper around another list. It is securable: a member of this type protects itself, so the validator accepts it. Its own members are excluded from default securing, because each change is checked against the owner of the list, not against the list. It derives from Collection<T>, and each change (Add, Insert, Remove, RemoveAt, Clear and the indexer setter) demands the write permission of the property on the owner of the list. The access kind of the check is AccessKind.Add, AccessKind.Remove or AccessKind.Replace, so audit records and denial messages say which change was refused, for instance Cannot add an item to Invoice.Lines. AccessKind is a set of flags, and AccessKind.Change selects every kind of change, which an auditor can use as a filter. The list checks the subject at the moment of the change, not the subject that read the property.

The owner is set on the first access. A property initializer such as = new SecurableList<InvoiceLine>() runs where the owner is not available, so the list starts without an owner. The getter generated by [RequiresPermission] knows the owner, so it calls SecurableCollection.SetOwner and passes this, the write permission and the property name. The owner can be set only once: setting the same owner again has no effect, and another owner makes the call throw, because a list shared by two objects would be checked against the wrong one. A list without an owner checks nothing. Before the first access, only the code of the owner can reach the list, and so can Entity Framework, which fills it when it loads the object.

The getter handles each declared type as follows:

  • IList<T>, ICollection<T>, IReadOnlyList<T>, IReadOnlyCollection<T>, IEnumerable<T> and SecurableList<T>: the getter sets the owner of the securable list and returns it. A read-only type is secured too, so casting it back to IList<T> does not bypass the check. If the property returns an ordinary mutable collection, such as a List<T>, the getter throws: it fails closed instead of returning an unprotected list. A sequence that is not a collection, such as a LINQ query, is returned as it is.
  • An immutable type, such as ImmutableArray<T>, is returned as it is.
  • A type that the getter cannot secure, such as List<T>, an array, a set or a dictionary, gets the warning AUTH003.

1using System.Collections.ObjectModel;
2using System.Diagnostics.CodeAnalysis;
3
4namespace Metalama.Samples.Authorization;
5
6/// <summary>
7/// A list that belongs to a securable object. Once it has an owner, each change demands the write permission of
8/// the property on the owner, then calls the owner (see <see cref="ISecurableCollectionOwner"/>). Reading the list
9/// checks nothing: the getter of the property has already checked the read permission.
10/// </summary>
11/// <remarks>
12/// <para>The developer initializes the property with a securable list, for instance
13/// <c>public IList<InvoiceLine> Lines { get; } = new SecurableList<InvoiceLine>();</c>. A property
14/// initializer runs where the owner is not available, so the list starts without an owner. The getter generated by
15/// <see cref="RequiresPermissionAttribute"/> sets the owner on the first access, and the owner can be set only once.</para>
16/// <para>A list without an owner is not checked. Only the code of the owner can reach it before the first access, and
17/// so can Entity Framework, which fills the list when it loads the object.</para>
18/// </remarks>
19// The list is securable: a member of this type protects itself. Its own members are not secured, because each
20// change is checked against the owner of the list, not against the list.
21[AuthorizationOptions( IsDefaultSecuringEnabled = false )]
22public sealed class SecurableList<T> : Collection<T>, ISecurableCollection, ISecurable
23{
24    private string? _writePermission;
25    private string? _member;
26    private string? _propertyName;
27
28    public SecurableList() { }
29
30    public SecurableList( IEnumerable<T> items ) : base( [.. items] ) { }
31
32    /// <summary>
33    /// Gets the object that owns the list, or <c>null</c> before the first access through the property.
34    /// </summary>
35    public ISecurable? Owner { get; private set; }
36
37    /// <inheritdoc />
38    public void SetOwner( ISecurable owner, string writePermission, string member, string propertyName )
39    {
40        if ( this.Owner == null )
41        {
42            this.Owner = owner;
43            this._writePermission = writePermission;
44            this._member = member;
45            this._propertyName = propertyName;
46        }
47        else if ( !ReferenceEquals( this.Owner, owner ) )
48        {
49            // Sharing a list between two objects would check the permission on the wrong one.
50            throw new InvalidOperationException( $"The list of {member} already belongs to another object." );
51        }
52    }
53
54    protected override void InsertItem( int index, T item )
55    {
56        this.OnChanging( AccessKind.Add );
57        base.InsertItem( index, item );
58    }
59
60    protected override void SetItem( int index, T item )
61    {
62        this.OnChanging( AccessKind.Replace );
63        base.SetItem( index, item );
64    }
65
66    protected override void RemoveItem( int index )
67    {
68        this.OnChanging( AccessKind.Remove );
69        base.RemoveItem( index );
70    }
71
72    protected override void ClearItems()
73    {
74        this.OnChanging( AccessKind.Remove );
75        base.ClearItems();
76    }
77
78    // The permission comes first: the owner validates its business rules only for authorized changes.
79    private void OnChanging( AccessKind accessKind )
80    {
81        if ( this.Owner == null )
82        {
83            return;
84        }
85
86        AccessGuard.Demand( this.Owner, this._writePermission!, this._member!, accessKind );
87
88        if ( this.Owner is ISecurableCollectionOwner owner )
89        {
90            owner.OnCollectionChanging( this._propertyName!, accessKind );
91        }
92    }
93}
94
95/// <summary>
96/// A collection that checks its changes against the permissions of its owner. See <see cref="SecurableList{T}"/>.
97/// </summary>
98public interface ISecurableCollection
99{
100    /// <summary>
101    /// Sets the owner of the collection and the permission required to change it. The owner can be set only once;
102    /// setting the same owner again has no effect.
103    /// </summary>
104    void SetOwner( ISecurable owner, string writePermission, string member, string propertyName );
105}
106
107/// <summary>
108/// Implemented by an object that validates the changes of its securable collections, for instance to enforce a
109/// business rule such as "only a draft invoice can be edited".
110/// </summary>
111public interface ISecurableCollectionOwner
112{
113    /// <summary>
114    /// Called before a change of a securable collection, after the permission has been checked. Throw to cancel
115    /// the change.
116    /// </summary>
117    /// <param name="propertyName">The name of the property whose collection changes.</param>
118    /// <param name="accessKind">The kind of change: <see cref="AccessKind.Add"/>, <see cref="AccessKind.Remove"/>
119    /// or <see cref="AccessKind.Replace"/>.</param>
120    void OnCollectionChanging( string propertyName, AccessKind accessKind );
121}
122
123/// <summary>
124/// The code generated by <see cref="RequiresPermissionAttribute"/> calls these methods in the getter of a collection
125/// property. The C# compiler chooses the overload from the type of the property and infers the item type.
126/// </summary>
127/// <remarks>
128/// <para>A securable collection gets its owner on the first access. A mutable collection that is not securable, such as a
129/// <see cref="List{T}"/> or an array, makes the getter throw: returning it would let any reader change the state of
130/// the owner. Any other value, such as an immutable collection or a LINQ query, is returned as it is.</para>
131/// </remarks>
132public static class SecurableCollection
133{
134    // A property declared with a securable type, such as SecurableList<T>, keeps its type.
135    [return: NotNullIfNotNull( nameof(collection) )]
136    public static TCollection? SetOwner<TCollection>( TCollection? collection, ISecurable owner, string writePermission, string member, string propertyName )
137        where TCollection : class, ISecurableCollection
138    {
139        collection?.SetOwner( owner, writePermission, member, propertyName );
140
141        return collection;
142    }
143
144    [return: NotNullIfNotNull( nameof(items) )]
145    public static IList<T>? SetOwner<T>( IList<T>? items, ISecurable owner, string writePermission, string member, string propertyName )
146        => Check<IList<T>, T>( items, owner, writePermission, member, propertyName );
147
148    [return: NotNullIfNotNull( nameof(items) )]
149    public static ICollection<T>? SetOwner<T>( ICollection<T>? items, ISecurable owner, string writePermission, string member, string propertyName )
150        => Check<ICollection<T>, T>( items, owner, writePermission, member, propertyName );
151
152    [return: NotNullIfNotNull( nameof(items) )]
153    public static IReadOnlyList<T>? SetOwner<T>( IReadOnlyList<T>? items, ISecurable owner, string writePermission, string member, string propertyName )
154        => Check<IReadOnlyList<T>, T>( items, owner, writePermission, member, propertyName );
155
156    [return: NotNullIfNotNull( nameof(items) )]
157    public static IReadOnlyCollection<T>? SetOwner<T>(
158        IReadOnlyCollection<T>? items,
159        ISecurable owner,
160        string writePermission,
161        string member,
162        string propertyName )
163        => Check<IReadOnlyCollection<T>, T>( items, owner, writePermission, member, propertyName );
164
165    [return: NotNullIfNotNull( nameof(items) )]
166    public static IEnumerable<T>? SetOwner<T>( IEnumerable<T>? items, ISecurable owner, string writePermission, string member, string propertyName )
167        => Check<IEnumerable<T>, T>( items, owner, writePermission, member, propertyName );
168
169    private static TCollection? Check<TCollection, T>(
170        TCollection? items,
171        ISecurable owner,
172        string writePermission,
173        string member,
174        string propertyName )
175        where TCollection : class, IEnumerable<T>
176    {
177        switch ( items )
178        {
179            case null:
180                return null;
181
182            case ISecurableCollection securableCollection:
183                securableCollection.SetOwner( owner, writePermission, member, propertyName );
184
185                return items;
186
187            // An array is fixed-size, so it reports IsReadOnly, but its items can be replaced.
188            case T[]:
189            case ICollection<T> { IsReadOnly: false }:
190                // Fail closed: a secured property must not return a mutable collection that checks nothing.
191                throw new InvalidOperationException(
192                    $"{member} returns a mutable {items.GetType().Name}. Initialize it with a {nameof(SecurableList<T>)}<T>." );
193
194            default:
195                return items;
196        }
197    }
198}
199

Business rules on collections go to the owner. After the permission check and before each change, the securable list calls ISecurableCollectionOwner.OnCollectionChanging on its owner, with the name of the property and the kind of change. The owner throws to cancel the change. Invoice uses it to refuse changes of the lines of an invoice that is not a draft, while its notes can change at any status:

void ISecurableCollectionOwner.OnCollectionChanging( string propertyName, AccessKind accessKind )
{
    if ( propertyName == nameof(this.Lines) )
    {
        this.EnsureStatus( InvoiceStatus.Draft );
    }
}

Source Code
1using System.Collections.Immutable;


2
3namespace Metalama.Samples.Authorization.Tests.CollectionProperty;












4
5public class Document : ISecurable
6{
7    private readonly SecurableList<string> _authors = [];




8
9    // The getter sets the owner of the securable list on the first access, so that changing the list requires Write.
10    public IList<string> Tags { get; } = new SecurableList<string>();




























11
12    public ICollection<int> Pages { get; } = new SecurableList<int>();
13
14    // A property can also be declared with the securable type.
15    public SecurableList<string> Reviewers { get; } = [];






16





17    // A read-only type is secured too: casting it back to IList<T> does not bypass the check.
18    public IReadOnlyList<string> Authors => this._authors;







19


20    // A sequence that is not a collection, and an immutable type, are returned as they are.
21    public IEnumerable<string> UpperTags => this.Tags.Select( t => t.ToUpperInvariant() );


























22
23    public ImmutableArray<string> Keywords { get; set; } = [];
24
25    // Types that the getter cannot secure: warning AUTH003.
    Warning AUTH003: 'Document.Approvers' returns the collection type 'List<string>', which the getter cannot secure: a caller who can read it may change its content without the write permission. Declare it as IList<T>, ICollection<T>, IReadOnlyList<T>, IReadOnlyCollection<T> or IEnumerable<T>, initialized with a SecurableList<T>, or as an immutable type.

26    public List<string> Approvers { get; } = [];





















































27
    Warning AUTH003: 'Document.Sections' returns the collection type 'string[]', which the getter cannot secure: a caller who can read it may change its content without the write permission. Declare it as IList<T>, ICollection<T>, IReadOnlyList<T>, IReadOnlyCollection<T> or IEnumerable<T>, initialized with a SecurableList<T>, or as an immutable type.

28    public string[] Sections { get; } = [];







29
    Warning AUTH003: 'Document.Metadata' returns the collection type 'Dictionary<string, string>', which the getter cannot secure: a caller who can read it may change its content without the write permission. Declare it as IList<T>, ICollection<T>, IReadOnlyList<T>, IReadOnlyCollection<T> or IEnumerable<T>, initialized with a SecurableList<T>, or as an immutable type.

30    public Dictionary<string, string> Metadata { get; } = [];



31}
32
Transformed Code
1using System.Collections.Immutable;
2using Metalama.Framework.RunTime;
3using Metalama.Framework.RunTime.Initialization;
4
5namespace Metalama.Samples.Authorization.Tests.CollectionProperty;
6
7public class Document : ISecurable, IInitializable
8{
9    private readonly SecurableList<string> _authors1 = [];
10
11    private SecurableList<string> _authors
12    {
13        get
14        {
15            AccessGuard.Demand(this, "Read", "Document._authors", AccessKind.Read);
16            return SecurableCollection.SetOwner(_authors1, this, "Write", "Document._authors", "_authors");
17        }
18
19        init
20        {
21            _authors1 = value;
22        }
23    }
24
25    private readonly IList<string> _tags = new SecurableList<string>(context: InitializationContext.WillInitialize).WithInitialize();
26
27    // The getter sets the owner of the securable list on the first access, so that changing the list requires Write.
28    public IList<string> Tags
29    {
30        get
31        {
32            AccessGuard.Demand(this, "Read", "Document.Tags", AccessKind.Read);
33            return SecurableCollection.SetOwner(_tags, this, "Write", "Document.Tags", "Tags");
34        }
35
36        private init
37        {
38            _tags = value;
39        }
40    }
41
42    private readonly ICollection<int> _pages = new SecurableList<int>(context: InitializationContext.WillInitialize).WithInitialize();
43
44    public ICollection<int> Pages
45    {
46        get
47        {
48            AccessGuard.Demand(this, "Read", "Document.Pages", AccessKind.Read);
49            return SecurableCollection.SetOwner(_pages, this, "Write", "Document.Pages", "Pages");
50        }
51
52        private init
53        {
54            _pages = value;
55        }
56    }
57
58    private readonly SecurableList<string> _reviewers = [];
59
60    // A property can also be declared with the securable type.
61    public SecurableList<string> Reviewers
62    {
63        get
64        {
65            AccessGuard.Demand(this, "Read", "Document.Reviewers", AccessKind.Read);
66            return SecurableCollection.SetOwner(_reviewers, this, "Write", "Document.Reviewers", "Reviewers");
67        }
68
69        private init
70        {
71            _reviewers = value;
72        }
73    }
74    // A read-only type is secured too: casting it back to IList<T> does not bypass the check.
75    public IReadOnlyList<string> Authors
76    {
77        get
78        {
79            AccessGuard.Demand(this, "Read", "Document.Authors", AccessKind.Read);
80            return SecurableCollection.SetOwner(Authors_Source, this, "Write", "Document.Authors", "Authors");
81        }
82    }
83
84    private IReadOnlyList<string> Authors_Source => this._authors;
85
86    // A sequence that is not a collection, and an immutable type, are returned as they are.
87    public IEnumerable<string> UpperTags
88    {
89        get
90        {
91            AccessGuard.Demand(this, "Read", "Document.UpperTags", AccessKind.Read);
92            return SecurableCollection.SetOwner(UpperTags_Source, this, "Write", "Document.UpperTags", "UpperTags");
93        }
94    }
95
96    private IEnumerable<string> UpperTags_Source => this.Tags.Select(t => t.ToUpperInvariant());
97
98    private ImmutableArray<string> _keywords = [];
99
100    public ImmutableArray<string> Keywords
101    {
102        get
103        {
104            AccessGuard.Demand(this, "Read", "Document.Keywords", AccessKind.Read);
105            return _keywords;
106        }
107
108        set
109        {
110            AccessGuard.Demand(this, "Write", "Document.Keywords", AccessKind.Write);
111            _keywords = value;
112        }
113    }
114
115    private readonly List<string> _approvers = [];
116
117    // Types that the getter cannot secure: warning AUTH003.
118    public List<string> Approvers
119    {
120        get
121        {
122            AccessGuard.Demand(this, "Read", "Document.Approvers", AccessKind.Read);
123            return _approvers;
124        }
125
126        private init
127        {
128            _approvers = value;
129        }
130    }
131
132    private readonly string[] _sections = [];
133
134    public string[] Sections
135    {
136        get
137        {
138            AccessGuard.Demand(this, "Read", "Document.Sections", AccessKind.Read);
139            return _sections;
140        }
141
142        private init
143        {
144            _sections = value;
145        }
146    }
147
148    private readonly Dictionary<string, string> _metadata = [];
149
150    public Dictionary<string, string> Metadata
151    {
152        get
153        {
154            AccessGuard.Demand(this, "Read", "Document.Metadata", AccessKind.Read);
155            return _metadata;
156        }
157
158        private init
159        {
160            _metadata = value;
161        }
162    }
163
164    public Document([AspectGenerated] InitializationContext context)
165    {
166        SecurityContext.BeginInitialization(this);
167        if (!context.IsHandled(InitializationSlot.OnConstructed))
168        {
169            this.OnConstructed(context);
170        }
171    }
172
173    public Document() : this(context: default)
174    {
175    }
176
177    public virtual void Initialize(InitializationContext context = default)
178    {
179        SecurityContext.EndInitialization(this);
180    }
181
182    protected virtual void OnConstructed(InitializationContext context = default)
183    {
184        SecurityContext.EndConstruction(this);
185    }
186}
187

Writing a securable class

The aspects check the accesses to the members of a class. They cannot check what the class does with the references that it returns or receives. A securable class therefore follows these rules, which the demo applies:

  • A collection property is initialized with a SecurableList<T>. The getter fails closed if it returns an ordinary list, and the validator warns about collection types that it cannot secure.
  • A reference to another secured object changes only through a method with a parameter check. A setter can only check the object that it belongs to, not the new value. So Invoice.Customer and Customer.Department have private setters, which only the class can call, and Invoice.ChangeCustomer and Customer.MoveTo check the permission on the new value. The private setters are checked too.
  • A guarantee that does not depend on the policy is structural. Company.Name and Department.Name are init-only, so no rule added to the policy later can allow renaming them.
  • A label identifies the object in messages without disclosing its data (see IHasSecurityLabel below).

Build-time configuration with hierarchical options

The aspects read their defaults from the AuthorizationOptions class, which implements IHierarchicalOptions<T> for the compilation, namespaces, types, members and parameters:

Option Default Effect
DefaultReadPermission Read Permission to read a field or property.
DefaultWritePermission Write Permission to write a field or property.
DefaultExecutePermission Write Permission to execute a method.
SecureNonPublicMembers false Whether [SecurableType] also secures internal and protected methods. Fields and properties are always secured.
IsDefaultSecuringEnabled true Whether [SecurableType] secures the target. Set it to false to exclude a namespace, a type or a member.

1using Metalama.Framework.Code;
2using Metalama.Framework.Options;
3
4namespace Metalama.Samples.Authorization;
5
6/// <summary>
7/// Build-time options of the authorization aspects. They decide <i>which</i> checks are generated, while the
8/// <see cref="IAuthorizationPolicy"/> decides at run time whether a check passes.
9/// </summary>
10/// <remarks>
11/// <para>Options are inherited from the project, namespaces, base types, base members and declaring types. Set them
12/// from a fabric with <c>amender.SetOptions(...)</c> or with the <see cref="AuthorizationOptionsAttribute"/>.</para>
13/// <para>Every property is nullable: <c>null</c> means "inherit the value".</para>
14/// <para>The defaults are applied by the aspects when a value is still <c>null</c>, and not by
15/// <c>IHierarchicalOptions.GetDefaultOptions</c>. A base type contributes its fully merged options to derived
16/// types, defaults included, and these override the namespace options of the derived type: a default
17/// <c>Read</c> on <c>Entity</c> would cancel a <c>ReadSensitive</c> set on the namespace of a derived class.</para>
18/// </remarks>
19public sealed class AuthorizationOptions :
20    IHierarchicalOptions<ICompilation>,
21    IHierarchicalOptions<INamespace>,
22    IHierarchicalOptions<INamedType>,
23    IHierarchicalOptions<IFieldOrProperty>,
24    IHierarchicalOptions<IMethod>,
25    IHierarchicalOptions<IParameter>,
26    IHierarchicalOptions<IIndexer>,
27    IHierarchicalOptions<IEvent>
28{
29    /// <summary>
30    /// Gets the permission required to read a field or property when <see cref="RequiresPermissionAttribute"/>
31    /// does not name one. The default is <see cref="PermissionNames.Read"/>.
32    /// </summary>
33    public string? DefaultReadPermission { get; init; }
34
35    /// <summary>
36    /// Gets the permission required to write a field or property when <see cref="RequiresPermissionAttribute"/>
37    /// does not name one. The default is <see cref="PermissionNames.Write"/>.
38    /// </summary>
39    public string? DefaultWritePermission { get; init; }
40
41    /// <summary>
42    /// Gets the permission required to execute a method when <see cref="RequiresPermissionAttribute"/> does not
43    /// name one. The default is <see cref="PermissionNames.Write"/>, because a method typically changes the object.
44    /// </summary>
45    public string? DefaultExecutePermission { get; init; }
46
47    /// <summary>
48    /// Gets a value indicating whether <see cref="SecurableTypeAttribute"/> also secures
49    /// <c>internal</c> and <c>protected</c> methods. Private methods are never secured by default. Fields and
50    /// properties are always secured, whatever their accessibility, because they hold the state of the object.
51    /// The default is <c>false</c>.
52    /// </summary>
53    public bool? SecureNonPublicMembers { get; init; }
54
55    /// <summary>
56    /// Gets a value indicating whether <see cref="SecurableTypeAttribute"/> secures the members it targets.
57    /// Set it to <c>false</c> to exclude a namespace, a type or a member. The default is <c>true</c>.
58    /// </summary>
59    public bool? IsDefaultSecuringEnabled { get; init; }
60
61    object IIncrementalObject.ApplyChanges( object changes, in ApplyChangesContext context )
62    {
63        var other = (AuthorizationOptions) changes;
64
65        return new AuthorizationOptions
66        {
67            DefaultReadPermission = other.DefaultReadPermission ?? this.DefaultReadPermission,
68            DefaultWritePermission = other.DefaultWritePermission ?? this.DefaultWritePermission,
69            DefaultExecutePermission = other.DefaultExecutePermission ?? this.DefaultExecutePermission,
70            SecureNonPublicMembers = other.SecureNonPublicMembers ?? this.SecureNonPublicMembers,
71            IsDefaultSecuringEnabled = other.IsDefaultSecuringEnabled ?? this.IsDefaultSecuringEnabled
72        };
73    }
74}
75

Options can be set at any level, and the nearest level wins. The demo uses all of them:

  • A project fabric also secures non-public methods:

    1using Metalama.Framework.Fabrics;
    2using Metalama.Framework.Options;
    3
    4namespace Metalama.Samples.Authorization.Demo;
    5
    6/// <summary>
    7/// Project-wide authorization options. They only affect classes with the <see cref="SecurableTypeAttribute"/>
    8/// aspect, here every <see cref="Entity"/>.
    9/// </summary>
    10internal sealed class AuthorizationFabric : ProjectFabric
    11{
    12    public override void AmendProject( IProjectAmender amender )
    13        => amender.SetOptions(
    14            new AuthorizationOptions
    15            {
    16                // internal and protected methods are secured too, because other code of this assembly
    17                // must not bypass checks by calling them. Fields and properties are always secured.
    18                SecureNonPublicMembers = true
    19            } );
    20}
    21
    
  • A namespace fabric makes finance data confidential. In the Finance namespace, reading any member requires ReadSensitive. The Budget class has no attribute at all:

    1using Metalama.Framework.Fabrics;
    2using Metalama.Framework.Options;
    3
    4namespace Metalama.Samples.Authorization.Demo.Finance;
    5
    6/// <summary>
    7/// Finance data is confidential: in this namespace, reading a field or property requires
    8/// <see cref="DemoPermissions.ReadSensitive"/> instead of <see cref="PermissionNames.Read"/>.
    9/// </summary>
    10internal sealed class FinanceFabric : NamespaceFabric
    11{
    12    public override void AmendNamespace( INamespaceAmender amender )
    13        => amender.SetOptions( new AuthorizationOptions { DefaultReadPermission = DemoPermissions.ReadSensitive } );
    14}
    15
    
  • The [AuthorizationOptions] attribute, which implements IHierarchicalOptionsProvider, sets options on a type or a member. On Customer, writing any property requires Approve, because commercial terms are a management decision. The contact details override this back to Write:

    1using Metalama.Samples.Authorization.RoleBased;
    2
    3namespace Metalama.Samples.Authorization.Demo.Sales;
    4
    5/// <summary>
    6/// A customer, managed by a <see cref="Department"/>.
    7/// </summary>
    8/// <remarks>
    9/// Commercial terms such as the credit limit are a management decision, so the type-level option makes
    10/// <see cref="DemoPermissions.Approve"/> the default permission to write any property. Contact details
    11/// override this back to <see cref="PermissionNames.Write"/>, and the tax ID has its own explicit permissions.
    12/// </remarks>
    13[AuthorizationOptions( DefaultWritePermission = DemoPermissions.Approve )]
    14public sealed class Customer( Department department, string name ) : Entity
    15{
    16    // Used by Entity Framework, which creates entities in a system scope.
    17    private Customer() : this( null!, null! ) { }
    18
    19    // The department changes only through MoveTo, which checks the destination. Moving a customer requires
    20    // ChangeOwnership, so the setter requires it too.
    21    [RequiresPermission( PermissionNames.Read, PermissionNames.ChangeOwnership )]
    22    public Department Department { get; private set; } = department;
    23
    24    [AuthorizationOptions( DefaultWritePermission = PermissionNames.Write )]
    25    public string Name { get; set; } = name;
    26
    27    [AuthorizationOptions( DefaultWritePermission = PermissionNames.Write )]
    28    public string? Email { get; set; }
    29
    30    [RequiresPermission( DemoPermissions.ReadSensitive, PermissionNames.Write )]
    31    public string? TaxId { get; set; }
    32
    33    public decimal CreditLimit { get; set; }
    34
    35    public int PaymentTermDays { get; set; } = 30;
    36
    37    protected override IRoleBasedSecurable? SecurityParent => this.Department;
    38
    39    /// <summary>
    40    /// Moves the customer to another department. The user needs <c>ChangeOwnership</c> on the customer
    41    /// and on the destination.
    42    /// </summary>
    43    [RequiresPermission( PermissionNames.ChangeOwnership )]
    44    public void MoveTo( [RequiresPermission( PermissionNames.ChangeOwnership )] Department target ) => this.Department = target;
    45
    46    public override string ToString() => this.Name;
    47}
    48
    
  • Options are inherited by overriding members. The library excludes the abstract RoleBasedSecurable.SecurityParent property from default securing, and the exclusion also applies to its overrides in the demo project.

Options decide which checks the aspects generate, at build time. The policy decides whether a check passes, at run time. This is why the role-permission rules are not options: in a real application, they are data that an administrator changes without rebuilding the application.

Note

The options class does not provide its defaults through IHierarchicalOptions.GetDefaultOptions. The options of a base type, including its defaults, override the namespace options of a derived type. A default Read on Entity would then cancel the ReadSensitive of the Finance namespace for Budget. Instead, the aspects apply the default when the merged value is still null.

The abstract layer at run time

The generated code calls AccessGuard.Demand:

1namespace Metalama.Samples.Authorization;
2
3/// <summary>
4/// The run-time entry point of the framework. The code generated by <see cref="RequiresPermissionAttribute"/>
5/// calls <see cref="Demand"/> before each secured operation.
6/// </summary>
7public static class AccessGuard
8{
9    /// <summary>
10    /// Throws an <see cref="AccessDeniedException"/> if the current subject does not have the permission
11    /// on <paramref name="securable"/>.
12    /// </summary>
13    /// <param name="securable">The object being accessed, or the argument for a parameter check.</param>
14    /// <param name="permission">The name of the required permission.</param>
15    /// <param name="member">The name of the member being accessed, for messages and audit.</param>
16    /// <param name="accessKind">The kind of access.</param>
17    /// <exception cref="InvalidOperationException">There is no current security context.</exception>
18    public static void Demand( ISecurable securable, string permission, string member, AccessKind accessKind )
19    {
20        if ( SecurityContext.IsSystem )
21        {
22            return;
23        }
24
25        // An object that is being initialized is set up by its creator with unlimited rights, and is not usable by
26        // anybody else (see SecurityContext.BeginInitialization).
27        if ( SecurityContext.TryGetInitializationState( securable, out var initializationState ) )
28        {
29            DemandDuringInitialization( securable, permission, member, accessKind, initializationState );
30
31            return;
32        }
33
34        var context = GetContext( member );
35        Permission requiredPermission = permission;
36
37        // The decision is cached in the current execution context (see SecurityContext). Each access is still
38        // audited, because the auditor records accesses, not decisions.
39        if ( !SecurityContext.TryGetCachedDecision( securable, requiredPermission, out var isGranted ) )
40        {
41            // The policy reads secured members, for instance the owner of the object.
42            // Running it in a system scope avoids recursive checks.
43            using ( SecurityContext.RunAsSystem() )
44            {
45                // A grant of a member that is running (see GrantsPermissionsAttribute) takes precedence over the policy.
46                isGranted = SecurityContext.IsGranted( securable, requiredPermission )
47                            || context.Policy.IsGranted( context.Subject, requiredPermission, securable );
48            }
49
50            SecurityContext.CacheDecision( securable, requiredPermission, isGranted );
51        }
52
53        if ( !isGranted )
54        {
55            Fail( context, CreateRecord( context, securable, requiredPermission, member, accessKind ), null );
56        }
57
58        // An access that changes the rights makes the cached decisions wrong: the cache of the execution context is
59        // cleared, and the decisions on the object are removed from the shared cache. The access is checked before the
60        // change, so the next check sees the change.
61        if ( IsRightsChanging( requiredPermission ) )
62        {
63            SecurityContext.ClearCachedDecisions();
64            (context.Policy as CachingAuthorizationPolicy)?.Invalidate( securable );
65        }
66
67        AuditGrant( context, securable, requiredPermission, member, accessKind );
68    }
69
70    private static void DemandDuringInitialization(
71        ISecurable securable,
72        Permission permission,
73        string member,
74        AccessKind accessKind,
75        InitializationState state )
76    {
77        // While the constructors run, the creator has unlimited rights. After them, it can only set properties, as the
78        // object initializer does. The access is not cached, but it is audited like a grant.
79        if ( SecurityContext.IsInitializing( securable )
80             && (state == InitializationState.Constructing || accessKind == AccessKind.Write) )
81        {
82            AuditGrant( GetContext( member ), securable, permission, member, accessKind );
83
84            return;
85        }
86
87        throw new InvalidOperationException(
88            $"Cannot access {member}: the object is not initialized. Its initialization ends after the object initializer, " +
89            $"at a call site compiled by Metalama. An object created in another way, for instance by reflection, must be " +
90            $"initialized with {nameof(ISecurable)}.{nameof(ISecurable.CompleteInitialization)}." );
91    }
92
93    // The permissions whose operations change the owner or the role assignments of an object, which the role-based
94    // policy reads.
95    private static bool IsRightsChanging( Permission permission )
96        => permission.Name is PermissionNames.ChangeOwnership or PermissionNames.ManageRoles;
97
98    // The auditor runs with the permissions of the subject, outside of the system scope.
99    private static void AuditGrant( ISecurityContext context, ISecurable securable, Permission permission, string member, AccessKind accessKind )
100    {
101        if ( context.Auditor?.AuditsGrantsOf( permission ) == true )
102        {
103            context.Auditor.OnAccessGranted( CreateRecord( context, securable, permission, member, accessKind ) );
104        }
105    }
106
107    // The label reads secured members, so it is computed in a system scope.
108    private static AccessRecord CreateRecord( ISecurityContext context, ISecurable securable, Permission permission, string member, AccessKind accessKind )
109    {
110        using ( SecurityContext.RunAsSystem() )
111        {
112            return new AccessRecord( context.Subject, permission, SecurityLabel.Of( securable ), member, accessKind );
113        }
114    }
115
116    /// <summary>
117    /// Denies an operation for a reason that is not a missing permission, for instance because a user tries to
118    /// assign a role to themself. The denial is audited like any other.
119    /// </summary>
120    /// <param name="securable">The object being accessed.</param>
121    /// <param name="permission">The permission of the operation.</param>
122    /// <param name="member">The name of the member being accessed.</param>
123    /// <param name="reason">The reason of the denial, included in the exception message.</param>
124    public static void Deny( ISecurable securable, string permission, string member, string reason )
125    {
126        var context = GetContext( member );
127
128        AccessRecord record;
129
130        using ( SecurityContext.RunAsSystem() )
131        {
132            record = new AccessRecord( context.Subject, permission, SecurityLabel.Of( securable ), member, AccessKind.Execute );
133        }
134
135        Fail( context, record, reason );
136    }
137
138    private static ISecurityContext GetContext( string member )
139        // Fail closed: a missing context is a configuration error, not a reason to skip security.
140        => SecurityContext.Current
141           ?? throw new InvalidOperationException(
142               $"Cannot access {member}: there is no current security context. " +
143               "Use SecurityContext.Use() or SecurityContext.RunAsSystem()." );
144
145    private static void Fail( ISecurityContext context, AccessRecord record, string? reason )
146    {
147        var exception = new AccessDeniedException( record, reason );
148        context.Auditor?.OnAccessDenied( exception );
149
150        throw exception;
151    }
152}
153
  • There is no check in a system scope, opened with SecurityContext.RunAsSystem(). Trusted infrastructure code uses it when no user operation is running: the demo uses it to seed the database and to create the entities that Entity Framework loads, and AccessGuard uses it to run the policy. A member that needs more permissions on its own object, or on an object that it receives, uses [GrantsPermissions] instead (see GrantsPermissions).
  • Without a current context, the access fails. A missing context is a configuration error, not a reason to skip security.
  • The policy runs in a system scope. The policy reads secured members, such as the owner of the object, and these reads must not trigger checks recursively.
  • The auditor runs outside of the system scope. An auditor that reads data or writes to a secured store does it with the permissions of the current subject, not with implicit privileges.
  • An access to an object that is being initialized is not checked (see Constructing an object).
  • AccessGuard.Deny refuses an operation for a reason that is not a missing permission, and audits it like any other denial. The role-based layer uses it to refuse that a user assigns a role to themself, revokes a role from themself, or makes themself the owner of an object.
  • The decision is cached in the current execution context. Each access is still audited, because the auditor records accesses, not decisions.
  • A granted access with ChangeOwnership or ManageRoles changes the data that the role-based policy reads. After it, AccessGuard clears the cache of the current execution context and of the execution contexts that contain it, and removes the decisions on the object from the shared cache (see Caching the decisions).

Caching the decisions

Every access to a field or a property runs a check, so the same decision is needed many times. For instance, Invoice.Approve reads Status and Lines and writes Status. The framework caches the decisions at two levels.

The first level is the execution context. SecurityContext.Use starts an execution context, for instance for a request. All the code that runs in the execution context shares its cache: the methods that it calls, the continuations of its async methods and the tasks that it starts. A method does not start an execution context when it enters an object. The check of the method has already put the object in the cache of the current execution context.

  • The cache is an immutable dictionary, ImmutableDictionary<ISecurable, ImmutableList<(Permission, bool)>>. It compares the objects by reference, and it contains the denials as well as the grants.
  • The AsyncLocal<T> field holds a reference to the execution context, and a new decision replaces the dictionary of the execution context with ImmutableInterlocked. The value of the AsyncLocal<T> field does not change. A decision cached by an async method is therefore visible to its caller after the method returns, and concurrent tasks add decisions without a lock.
  • A grant breaks the execution context. SecurityContext.Grant starts a child execution context with an empty cache, because the decisions of the parent do not take the grant into account. When the grant ends, the parent execution context is restored, without the decisions of the child, which took the grant into account.
  • The cache is cleared only after an access that changes the rights: a granted ChangeOwnership or ManageRoles. The access is checked before the change, so the next checks of the operation see the new rights. Any other change of the rights applies to the execution contexts that start after it. A long-running operation, such as a background job, keeps the decisions of its start. It should start an execution context for each unit of work.

The second level is shared by all the execution contexts of the process. CachingAuthorizationPolicy wraps a policy and caches its decisions in an IMemoryCache. It is called only when the cache of the execution context does not contain the decision, so it does not change the consistency of an operation.

  • The keys identify the subject and the object, not their instances, because each request loads new instances. The demo uses the class and the database key of each entity. An object without a key, for instance an invoice that has not been saved, is not cached.
  • The cache is invalidated manually. Invalidate removes the decisions on an object, and on the objects that depend on it, for instance the objects that inherit their roles from it. InvalidateAll removes all decisions. Each object has a cancellation token, stored in the memory cache too, so that the tokens of the objects that are no longer used are removed. When a token is evicted, it is canceled, so no decision survives the token that invalidates it.
  • The tokens are taken before the policy runs. When an invalidation happens while the policy runs, the new entry has a canceled token and expires immediately.
  • A decision is shared only when the data that it was made from is current. Each invalidation increments CachingAuthorizationPolicy.Generation, and the data access layer calls RecordLoad when it loads an object. A decision on an object, or on one of its dependencies, that was loaded before the last invalidation may come from data that has changed since then, so it is not shared. Without this rule, a request that loaded an invoice before another request gave it to a new owner would cache the decision of the former owner under a new token, and every other request would see it until it expires. A decision on an object whose load was not recorded is not shared either.
  • A decision also expires after five minutes, even without invalidation.

1using System.Runtime.CompilerServices;
2using Microsoft.Extensions.Caching.Memory;
3using Microsoft.Extensions.Primitives;
4
5namespace Metalama.Samples.Authorization;
6
7/// <summary>
8/// An <see cref="IAuthorizationPolicy"/> that caches the decisions of another policy in an <see cref="IMemoryCache"/>,
9/// for all the execution contexts of the process. Each execution context has its own cache of decisions (see
10/// <see cref="SecurityContext"/>), so this policy is called only once per execution context for an object and a
11/// permission, and each operation runs with a consistent set of decisions.
12/// </summary>
13/// <remarks>
14/// <para>The cache is invalidated manually. Call <see cref="Invalidate"/> after a change of the data that the policy
15/// reads for an object, for instance its owner or its role assignments, and <see cref="InvalidateAll"/> after a change
16/// of the rules. Invalidating an object also invalidates the decisions on the objects that depend on it, as returned
17/// by the function given to the constructor, for instance the objects that inherit their roles from it.</para>
18/// <para>The keys identify the objects and the subjects, not their instances, because an application such as a web
19/// application loads new instances in each request. An object or a subject without a key, for instance an object
20/// that has not been saved yet, is not cached.</para>
21/// <para>A decision is cached only when the data that it was made from is current. Each invalidation increments
22/// <see cref="Generation"/>, and the application calls <see cref="RecordLoad"/> when it loads an object. A decision on
23/// an object, or on an object that depends on another one, that was loaded before the last invalidation may have been
24/// made from data that has changed since then, so it is not cached. An object whose load was not recorded is not
25/// cached either.</para>
26/// <para>An execution context that started before an invalidation keeps its own decisions until it ends.</para>
27/// </remarks>
28public sealed class CachingAuthorizationPolicy : IAuthorizationPolicy, IDisposable
29{
30    private readonly IAuthorizationPolicy _inner;
31    private readonly IMemoryCache _cache;
32    private readonly Func<object, object?> _getKey;
33    private readonly Func<ISecurable, IEnumerable<ISecurable>> _getDependencies;
34    private readonly TimeSpan _expiration;
35    private readonly ConditionalWeakTable<object, StrongBox<long>> _loadGenerations = new();
36    private readonly Lock _tokenLock = new();
37    private CancellationTokenSource _allToken = new();
38    private long _generation;
39
40    /// <summary>
41    /// Initializes a new <see cref="CachingAuthorizationPolicy"/>.
42    /// </summary>
43    /// <param name="inner">The policy whose decisions are cached.</param>
44    /// <param name="cache">The memory cache. It can be shared with other components: the keys of this policy are private.</param>
45    /// <param name="getKey">Returns the key that identifies a subject or a securable object, or <c>null</c> when the
46    /// decisions on it must not be cached.</param>
47    /// <param name="getDependencies">Returns the objects whose data the policy reads to decide on an object, in
48    /// addition to the object itself, for instance its parents. It runs in a system scope.</param>
49    /// <param name="expiration">The time after which a decision expires, even without invalidation.</param>
50    public CachingAuthorizationPolicy(
51        IAuthorizationPolicy inner,
52        IMemoryCache cache,
53        Func<object, object?> getKey,
54        Func<ISecurable, IEnumerable<ISecurable>>? getDependencies = null,
55        TimeSpan? expiration = null )
56    {
57        this._inner = inner;
58        this._cache = cache;
59        this._getKey = getKey;
60        this._getDependencies = getDependencies ?? (_ => []);
61        this._expiration = expiration ?? TimeSpan.FromMinutes( 5 );
62    }
63
64    /// <summary>
65    /// Gets the number of invalidations since the creation of the policy.
66    /// </summary>
67    public long Generation => Interlocked.Read( ref this._generation );
68
69    /// <summary>
70    /// Records that an object has just been loaded, with the current data of the store. Call it before the data of the
71    /// object is read, for instance when the object is created by the data access layer.
72    /// </summary>
73    public void RecordLoad( object value ) => this._loadGenerations.AddOrUpdate( value, new StrongBox<long>( this.Generation ) );
74
75    public bool IsGranted( ISubject subject, Permission permission, ISecurable securable )
76    {
77        var subjectKey = this._getKey( subject );
78        var securableKey = this._getKey( securable );
79
80        if ( subjectKey == null || securableKey == null )
81        {
82            return this._inner.IsGranted( subject, permission, securable );
83        }
84
85        var key = new CacheKey( subjectKey, securableKey, permission.Name );
86
87        if ( this._cache.TryGetValue( key, out bool isGranted ) )
88        {
89            return isGranted;
90        }
91
92        var dependencies = this._getDependencies( securable ).ToList();
93
94        // The tokens are taken before the decision: when an invalidation happens while the inner policy runs, the
95        // tokens are already canceled when the entry is added, and the entry expires immediately.
96        var tokens = this.GetTokens( securableKey, dependencies );
97
98        isGranted = this._inner.IsGranted( subject, permission, securable );
99
100        if ( tokens != null && this.IsCurrent( securable, dependencies ) )
101        {
102            // The size is required when the cache has a size limit.
103            var options = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = this._expiration, Size = 1 };
104
105            foreach ( var token in tokens )
106            {
107                options.ExpirationTokens.Add( token );
108            }
109
110            this._cache.Set( key, isGranted, options );
111        }
112
113        return isGranted;
114    }
115
116    /// <summary>
117    /// Removes the cached decisions on an object, and on the objects that depend on it.
118    /// </summary>
119    public void Invalidate( ISecurable securable )
120    {
121        var key = this._getKey( securable );
122
123        if ( key == null )
124        {
125            return;
126        }
127
128        lock ( this._tokenLock )
129        {
130            Interlocked.Increment( ref this._generation );
131
132            if ( this._cache.TryGetValue( new TokenKey( key ), out CancellationTokenSource? source ) )
133            {
134                this._cache.Remove( new TokenKey( key ) );
135
136                // The token is canceled here, and not in the eviction callback, which runs asynchronously.
137                source!.Cancel();
138                source.Dispose();
139            }
140        }
141    }
142
143    /// <summary>
144    /// Removes all the cached decisions, for instance after a change of the rules of the policy.
145    /// </summary>
146    public void InvalidateAll()
147    {
148        lock ( this._tokenLock )
149        {
150            Interlocked.Increment( ref this._generation );
151
152            var previous = this._allToken;
153            this._allToken = new CancellationTokenSource();
154            previous.Cancel();
155            previous.Dispose();
156        }
157    }
158
159    public void Dispose()
160    {
161        lock ( this._tokenLock )
162        {
163            this._allToken.Dispose();
164        }
165    }
166
167    // A decision is made from the data of the object and of its dependencies, so all of them must have been loaded
168    // after the last invalidation.
169    private bool IsCurrent( ISecurable securable, List<ISecurable> dependencies )
170    {
171        var generation = this.Generation;
172
173        return this.IsLoadedIn( securable, generation ) && dependencies.TrueForAll( dependency => this.IsLoadedIn( dependency, generation ) );
174    }
175
176    private bool IsLoadedIn( object value, long generation )
177        => this._loadGenerations.TryGetValue( value, out var loadGeneration ) && loadGeneration.Value >= generation;
178
179    // Returns null when the decision cannot be cached because a dependency cannot be invalidated.
180    private List<IChangeToken>? GetTokens( object securableKey, List<ISecurable> dependencies )
181    {
182        var dependencyKeys = new List<object>();
183
184        foreach ( var dependency in dependencies )
185        {
186            var dependencyKey = this._getKey( dependency );
187
188            if ( dependencyKey == null )
189            {
190                return null;
191            }
192
193            dependencyKeys.Add( dependencyKey );
194        }
195
196        // The tokens are read under the lock, so that no token source is disposed while its token is read.
197        lock ( this._tokenLock )
198        {
199            var tokens = new List<IChangeToken> { new CancellationChangeToken( this._allToken.Token ), this.GetObjectToken( securableKey ) };
200
201            foreach ( var dependencyKey in dependencyKeys )
202            {
203                tokens.Add( this.GetObjectToken( dependencyKey ) );
204            }
205
206            return tokens;
207        }
208    }
209
210    // The token of an object is stored in the cache too, so that the tokens of the objects that are no longer used are
211    // removed. When the token is evicted, it is canceled, so that no decision survives the token that invalidates it.
212    // The caller holds the lock, which makes sure that only one token exists for an object: a decision bound to a
213    // second token would not be invalidated.
214    private CancellationChangeToken GetObjectToken( object key )
215    {
216        if ( !this._cache.TryGetValue( new TokenKey( key ), out CancellationTokenSource? source ) )
217        {
218            source = new CancellationTokenSource();
219
220            var options = new MemoryCacheEntryOptions { SlidingExpiration = 2 * this._expiration, Size = 1 };
221
222            // Invalidate removes the entry after it has canceled and disposed the token source. Any other eviction
223            // cancels it here, under the lock, so that it is not disposed while another thread reads its token.
224            options.RegisterPostEvictionCallback(
225                ( _, value, reason, _ ) =>
226                {
227                    if ( reason != EvictionReason.Removed )
228                    {
229                        lock ( this._tokenLock )
230                        {
231                            var evicted = (CancellationTokenSource) value!;
232                            evicted.Cancel();
233                            evicted.Dispose();
234                        }
235                    }
236                } );
237
238            this._cache.Set( new TokenKey( key ), source, options );
239        }
240
241        return new CancellationChangeToken( source!.Token );
242    }
243
244    // Private types, so that the keys cannot collide with the keys of other components that share the cache.
245    private sealed record CacheKey( object Subject, object Securable, string Permission );
246
247    private sealed record TokenKey( object Securable );
248}
249

In the demo, PermissionCacheInvalidationInterceptor invalidates the cache when a change is visible to the other requests: after the save, or, when the save runs in an explicit transaction, after the commit. A rolled-back transaction invalidates nothing. A changed or deleted entity is invalidated, because its owner or its parent may have changed. A change of the role assignments invalidates all the decisions, because the roles of an object apply to all its descendants, and role assignments change rarely. DatabaseAuthorizationPolicy raises its Reloaded event after it reloads the rules, and the cache is cleared then. SystemScopeMaterializationInterceptor records the load of each entity.

The two interceptors are single instances. Entity Framework creates an internal service provider for each set of interceptor instances, so an instance for each application would create one per test. They find the cache through InvoicingDbContext.CachingPolicy.

1using System.Data.Common;
2using System.Runtime.CompilerServices;
3using Microsoft.EntityFrameworkCore;
4using Microsoft.EntityFrameworkCore.Diagnostics;
5
6namespace Metalama.Samples.Authorization.Demo.Data;
7
8/// <summary>
9/// Invalidates the decisions of <see cref="CachingAuthorizationPolicy"/> after a save that changes the data that the
10/// policy reads.
11/// </summary>
12/// <remarks>
13/// <para>A changed or deleted entity is invalidated: its owner or its parent may have changed. A change of the role
14/// assignments invalidates all the decisions, because the roles of an object apply to all its descendants. Role
15/// assignments change rarely, so this costs little.</para>
16/// <para>The invalidation runs when the new data is visible to the other requests: after the save, or, when the save
17/// runs in an explicit transaction, after the commit of the transaction. A rolled-back transaction invalidates
18/// nothing.</para>
19/// </remarks>
20public sealed class PermissionCacheInvalidationInterceptor : SaveChangesInterceptor, IDbTransactionInterceptor
21{
22    public static PermissionCacheInvalidationInterceptor Instance { get; } = new();
23
24    private readonly ConditionalWeakTable<DbContext, PendingInvalidation> _pending = new();
25
26    public override InterceptionResult<int> SavingChanges( DbContextEventData eventData, InterceptionResult<int> result )
27    {
28        this.Collect( eventData.Context );
29
30        return result;
31    }
32
33    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
34        DbContextEventData eventData,
35        InterceptionResult<int> result,
36        CancellationToken cancellationToken = default )
37    {
38        this.Collect( eventData.Context );
39
40        return ValueTask.FromResult( result );
41    }
42
43    public override int SavedChanges( SaveChangesCompletedEventData eventData, int result )
44    {
45        this.OnSaved( eventData.Context );
46
47        return result;
48    }
49
50    public override ValueTask<int> SavedChangesAsync( SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default )
51    {
52        this.OnSaved( eventData.Context );
53
54        return ValueTask.FromResult( result );
55    }
56
57    // A failed save outside of a transaction changed nothing. In a transaction, the changes of the previous saves are
58    // still pending, and the end of the transaction decides.
59    public override void SaveChangesFailed( DbContextErrorEventData eventData )
60    {
61        if ( eventData.Context is { Database.CurrentTransaction: null } context )
62        {
63            this._pending.Remove( context );
64        }
65    }
66
67    public override Task SaveChangesFailedAsync( DbContextErrorEventData eventData, CancellationToken cancellationToken = default )
68    {
69        this.SaveChangesFailed( eventData );
70
71        return Task.CompletedTask;
72    }
73
74    void IDbTransactionInterceptor.TransactionCommitted( DbTransaction transaction, TransactionEndEventData eventData ) => this.Invalidate( eventData.Context );
75
76    Task IDbTransactionInterceptor.TransactionCommittedAsync( DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken )
77    {
78        this.Invalidate( eventData.Context );
79
80        return Task.CompletedTask;
81    }
82
83    void IDbTransactionInterceptor.TransactionRolledBack( DbTransaction transaction, TransactionEndEventData eventData ) => this.Discard( eventData.Context );
84
85    Task IDbTransactionInterceptor.TransactionRolledBackAsync( DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken )
86    {
87        this.Discard( eventData.Context );
88
89        return Task.CompletedTask;
90    }
91
92    private void Collect( DbContext? context )
93    {
94        if ( context == null )
95        {
96            return;
97        }
98
99        var tracker = context.ChangeTracker;
100        tracker.DetectChanges();
101
102        var entities = tracker.Entries<Entity>().Where( e => e.State is EntityState.Modified or EntityState.Deleted ).Select( e => e.Entity );
103        var rolesChanged = tracker.Entries<RoleAssignmentRecord>().Any( e => e.State is not EntityState.Unchanged );
104
105        // The saves of a transaction add up until the transaction ends.
106        var pending = this._pending.GetValue( context, _ => new PendingInvalidation() );
107
108        lock ( pending )
109        {
110            pending.Entities.AddRange( entities );
111            pending.RolesChanged |= rolesChanged;
112        }
113    }
114
115    private void OnSaved( DbContext? context )
116    {
117        if ( context is { Database.CurrentTransaction: null } )
118        {
119            this.Invalidate( context );
120        }
121    }
122
123    private void Discard( DbContext? context )
124    {
125        if ( context != null )
126        {
127            this._pending.Remove( context );
128        }
129    }
130
131    private void Invalidate( DbContext? context )
132    {
133        if ( context == null || !this._pending.TryGetValue( context, out var pending ) )
134        {
135            return;
136        }
137
138        this._pending.Remove( context );
139
140        var cache = ((InvoicingDbContext) context).CachingPolicy;
141
142        if ( pending.RolesChanged )
143        {
144            cache.InvalidateAll();
145
146            return;
147        }
148
149        foreach ( var entity in pending.Entities )
150        {
151            cache.Invalidate( entity );
152        }
153    }
154
155    private sealed class PendingInvalidation
156    {
157        public List<Entity> Entities { get; } = [];
158
159        public bool RolesChanged { get; set; }
160    }
161}
162

What the messages and audit records disclose

A denial message is shown to a user who does not have access to the object, and audit records end up in log files. Both must not disclose the data that the check protects. AccessDeniedException and AccessRecord therefore do not reference the securable object: they contain only its label. A class implements IHasSecurityLabel to provide a label made of non-sensitive data, typically its type name and its identifier, such as Customer #1. Other objects are labeled with their type name. A message never calls ToString, which often returns a name, and a logger that serializes the exception cannot reach the secured members.

What the auditor records

The auditor receives every denial. It can also select permissions whose grants it records, with IAccessAuditor.AuditsGrantsOf. The demo records the grants of ManageRoles and ChangeOwnership, because these operations change who can do what: an audit trail of denials alone would not show who gave a right to whom.

Trust boundary

The framework enforces authorization inside a process. It protects against missing checks, not against malicious code: any code of the process can call SecurityContext.Use, SecurityContext.Grant or SecurityContext.RunAsSystem. Only trusted infrastructure calls them, such as the authentication middleware or the database seeder. SecurityContext.Current has no public setter, so the only way to change it is a scope that restores the previous value. A context or a system scope also applies to the tasks started while it is active, because AsyncLocal<T> values flow into them. SecurityContext.Use ends a system scope until its own scope is disposed: switching to a subject drops the privileges of the system scope.

SecurityContext stores the current execution context, which holds the context, the grants, the cache and the objects being initialized, and the system scope in AsyncLocal<T> fields. They follow async code and stay isolated between concurrent requests. The scopes are SecurityScope structures, so opening one does not allocate an object. Disposing the default value of the structure does nothing.

1using System.Collections.Immutable;
2using System.Runtime.CompilerServices;
3
4namespace Metalama.Samples.Authorization;
5
6/// <summary>
7/// Gives access to the current <see cref="ISecurityContext"/>. The context, its permission grants, its cache of
8/// decisions, the objects being initialized and the system scope flow with the logical call
9/// (<see cref="AsyncLocal{T}"/>), so they follow <c>async</c> code and stay isolated between concurrent operations.
10/// </summary>
11/// <remarks>
12/// <para>The framework enforces authorization inside a process against missing checks. It is not a sandbox: any
13/// code of the process can call <see cref="Use"/>, <see cref="Grant"/> or <see cref="RunAsSystem"/>. Only trusted
14/// infrastructure, such as an authentication middleware or a database seeder, and the code generated by the
15/// aspects should call them.</para>
16/// <para>A context, a grant or a system scope also applies to the tasks started while it is active, because
17/// <see cref="AsyncLocal{T}"/> values flow into them.</para>
18/// <para>Each execution context caches the decisions of the policy, by object and permission. <see cref="Use"/>
19/// starts an execution context, and so does <see cref="Grant"/>, which breaks the execution context of its caller.
20/// All the code that runs in an execution context, including the methods that it calls and the tasks that it starts,
21/// shares its cache. The cache is cleared only after an access that changes the rights, see
22/// <see cref="ClearCachedDecisions"/>. Other changes of the rights apply to the execution contexts that start after
23/// them.</para>
24/// </remarks>
25public static class SecurityContext
26{
27    private static readonly ImmutableDictionary<ISecurable, ImmutableList<Permission>> _noGrants =
28        ImmutableDictionary.Create<ISecurable, ImmutableList<Permission>>( ReferenceEqualityComparer.Instance );
29
30    private static readonly ImmutableDictionary<ISecurable, ImmutableList<(Permission Permission, bool IsGranted)>> _emptyCache =
31        ImmutableDictionary.Create<ISecurable, ImmutableList<(Permission Permission, bool IsGranted)>>( ReferenceEqualityComparer.Instance );
32
33    private static readonly ImmutableHashSet<ISecurable> _noObjects = ImmutableHashSet.Create<ISecurable>( ReferenceEqualityComparer.Instance );
34
35    // The grants and the cache belong to the execution context: when the context changes, they do not apply.
36    private static readonly AsyncLocal<ExecutionContext?> _current = new();
37    private static readonly AsyncLocal<bool> _isSystem = new();
38
39    // The objects whose initialization has started and not ended, with their state. The table does not keep the
40    // objects alive.
41    private static readonly ConditionalWeakTable<ISecurable, StrongBox<InitializationState>> _initializationStates = new();
42
43    /// <summary>
44    /// Gets the current security context. When it is <c>null</c>, every secured operation fails,
45    /// except in a system scope. Set it with <see cref="Use"/>.
46    /// </summary>
47    public static ISecurityContext? Current => _current.Value?.Context;
48
49    /// <summary>
50    /// Gets a value indicating whether the code runs in a system scope, where no permission is checked.
51    /// </summary>
52    public static bool IsSystem => _isSystem.Value;
53
54    /// <summary>
55    /// Sets <see cref="Current"/> until the returned scope is disposed, then restores the previous context.
56    /// The new execution context starts with an empty cache, and the grants of the previous context do not apply to it.
57    /// </summary>
58    /// <remarks>
59    /// Switching to a subject ends a system scope until the returned scope is disposed: the code then runs with the
60    /// permissions of the subject, and the system scope is restored after it.
61    /// </remarks>
62    public static SecurityScope Use( ISecurityContext? context )
63    {
64        var previous = _current.Value;
65        var previousIsSystem = _isSystem.Value;
66
67        _current.Value = new ExecutionContext( context, _noGrants, previous, sharesInitialization: false );
68        _isSystem.Value = false;
69
70        return new SecurityScope( previous, previousIsSystem );
71    }
72
73    /// <summary>
74    /// Grants permissions on one object to the current subject until the returned scope is disposed. The grant
75    /// applies to <paramref name="securable"/> only, and only in the current context.
76    /// </summary>
77    /// <remarks>
78    /// <para>The code generated by <see cref="GrantsPermissionsAttribute"/> calls this method after the permission
79    /// check of the member. A grant takes precedence over the policy, including its denials.</para>
80    /// <para>A grant breaks the execution context: it starts a child execution context with an empty cache, because
81    /// the decisions cached by the parent do not take the grant into account. When the grant ends, the parent execution
82    /// context is restored, without the decisions of the child, which took the grant into account.</para>
83    /// </remarks>
84    /// <param name="securable">The object on which the permissions are granted. When it is <c>null</c>, for instance a
85    /// null argument, nothing is granted.</param>
86    /// <param name="permissions">The names of the granted permissions.</param>
87    public static SecurityScope Grant( ISecurable? securable, params string[] permissions )
88    {
89        if ( securable == null )
90        {
91            return default;
92        }
93
94        var parent = _current.Value;
95        var grants = parent?.Grants ?? _noGrants;
96        var granted = grants.GetValueOrDefault( securable, ImmutableList<Permission>.Empty );
97
98        foreach ( var permission in permissions )
99        {
100            granted = granted.Add( permission );
101        }
102
103        _current.Value = new ExecutionContext( parent?.Context, grants.SetItem( securable, granted ), parent, sharesInitialization: true );
104
105        return new SecurityScope( parent );
106    }
107
108    /// <summary>
109    /// Disables permission checks until the returned scope is disposed. Use it for trusted code such as
110    /// seeding a database. The framework also uses it while a policy runs.
111    /// </summary>
112    public static SecurityScope RunAsSystem()
113    {
114        var previous = _isSystem.Value;
115        _isSystem.Value = true;
116
117        return new SecurityScope( previous );
118    }
119
120    /// <summary>
121    /// Starts the initialization of an object. The code generated by <see cref="SecurableTypeAttribute"/> calls this
122    /// method before the code of the constructors.
123    /// </summary>
124    /// <remarks>
125    /// <para>The caller creates the object, so it may set up the object with unlimited rights: the object is not
126    /// visible to any other code before its construction ends. While the constructors run, the current execution
127    /// context accesses the object without checks. After the constructors, and until <see cref="EndInitialization"/>,
128    /// it can only set its properties, as an object initializer does: the getters and the methods of the object throw
129    /// an <see cref="InvalidOperationException"/>, in every execution context. An object whose initialization never
130    /// ends, because it was created where Metalama could not rewrite the call site, is therefore unusable instead of
131    /// open. The accesses to other objects are checked as usual.</para>
132    /// <para>Nothing is recorded in a system scope, where nothing is checked anyway, or without an execution context,
133    /// where every check fails.</para>
134    /// </remarks>
135    public static void BeginInitialization( ISecurable securable )
136    {
137        if ( !_isSystem.Value && _current.Value is { } context )
138        {
139            // In a chain of constructors, each class starts the initialization: the first call records it.
140            _initializationStates.TryAdd( securable, new StrongBox<InitializationState>( InitializationState.Constructing ) );
141            context.BeginInitialization( securable );
142        }
143    }
144
145    /// <summary>
146    /// Records that the constructors of an object have run. The code generated by <see cref="SecurableTypeAttribute"/>
147    /// calls this method at the end of the last constructor, before the object initializer.
148    /// </summary>
149    public static void EndConstruction( ISecurable securable )
150    {
151        if ( _initializationStates.TryGetValue( securable, out var state ) )
152        {
153            state.Value = InitializationState.Constructed;
154        }
155    }
156
157    /// <summary>
158    /// Ends the initialization of an object, after its constructors and its object initializer. The code generated by
159    /// <see cref="SecurableTypeAttribute"/> calls this method from the <c>Initialize</c> method, which the call sites
160    /// of the constructors call after the object initializer. Code that creates an object elsewhere calls
161    /// <see cref="ISecurable.CompleteInitialization"/>.
162    /// </summary>
163    public static void EndInitialization( ISecurable securable )
164    {
165        _initializationStates.Remove( securable );
166
167        for ( var context = _current.Value; context != null; context = context.Parent )
168        {
169            context.EndInitialization( securable );
170        }
171    }
172
173    /// <summary>
174    /// Clears the cached decisions of the current execution context and of the execution contexts that contain it.
175    /// <see cref="AccessGuard"/> calls this method after an access that changes the rights, such as a change of the
176    /// owner or of the role assignments, so that the next checks of the current operation see the change.
177    /// </summary>
178    public static void ClearCachedDecisions()
179    {
180        for ( var context = _current.Value; context != null; context = context.Parent )
181        {
182            context.ClearCache();
183        }
184    }
185
186    internal static bool IsInitialized( ISecurable securable ) => !_initializationStates.TryGetValue( securable, out _ );
187
188    internal static bool TryGetInitializationState( ISecurable securable, out InitializationState state )
189    {
190        if ( _initializationStates.TryGetValue( securable, out var box ) )
191        {
192            state = box.Value;
193
194            return true;
195        }
196
197        state = default;
198
199        return false;
200    }
201
202    // Determines whether the current execution context is the one that creates the object.
203    internal static bool IsInitializing( ISecurable securable )
204    {
205        for ( var context = _current.Value; context != null; context = context.SharesInitialization ? context.Parent : null )
206        {
207            if ( context.Initializing.Contains( securable ) )
208            {
209                return true;
210            }
211        }
212
213        return false;
214    }
215
216    internal static bool IsGranted( ISecurable securable, Permission permission )
217        => _current.Value?.Grants.TryGetValue( securable, out var granted ) == true && granted.Contains( permission );
218
219    internal static bool TryGetCachedDecision( ISecurable securable, Permission permission, out bool isGranted )
220    {
221        if ( _current.Value?.Cache.TryGetValue( securable, out var decisions ) == true )
222        {
223            foreach ( var decision in decisions )
224            {
225                if ( decision.Permission == permission )
226                {
227                    isGranted = decision.IsGranted;
228
229                    return true;
230                }
231            }
232        }
233
234        isGranted = false;
235
236        return false;
237    }
238
239    internal static void CacheDecision( ISecurable securable, Permission permission, bool isGranted )
240        => _current.Value?.CacheDecision( securable, permission, isGranted );
241
242    internal static void Restore( ExecutionContext? context ) => _current.Value = context;
243
244    internal static void RestoreIsSystem( bool isSystem ) => _isSystem.Value = isSystem;
245
246    /// <summary>
247    /// An execution context: the security context, the grants, the cache of decisions and the objects being
248    /// initialized. The object is shared by all the code that runs in the execution context, including the
249    /// continuations of <c>async</c> methods and the tasks that it starts, so a decision cached by a method that has
250    /// returned is visible to its caller. The grants never change. The cache and the objects being initialized are
251    /// immutable collections, which a change replaces atomically, so that concurrent tasks can change them without a
252    /// lock.
253    /// </summary>
254    internal sealed class ExecutionContext(
255        ISecurityContext? context,
256        ImmutableDictionary<ISecurable, ImmutableList<Permission>> grants,
257        ExecutionContext? parent,
258        bool sharesInitialization )
259    {
260        private ImmutableDictionary<ISecurable, ImmutableList<(Permission Permission, bool IsGranted)>> _cache = _emptyCache;
261        private ImmutableHashSet<ISecurable> _initializing = _noObjects;
262
263        public ISecurityContext? Context { get; } = context;
264
265        public ImmutableDictionary<ISecurable, ImmutableList<Permission>> Grants { get; } = grants;
266
267        /// <summary>
268        /// Gets the execution context that was current when this one started.
269        /// </summary>
270        public ExecutionContext? Parent { get; } = parent;
271
272        /// <summary>
273        /// Gets a value indicating whether the objects being initialized in the parent are also being initialized in
274        /// this execution context. It is <c>true</c> for a grant, which keeps the subject, and <c>false</c> for
275        /// <see cref="Use"/>, which changes it.
276        /// </summary>
277        public bool SharesInitialization { get; } = sharesInitialization;
278
279        public ImmutableDictionary<ISecurable, ImmutableList<(Permission Permission, bool IsGranted)>> Cache => Volatile.Read( ref this._cache );
280
281        public ImmutableHashSet<ISecurable> Initializing => Volatile.Read( ref this._initializing );
282
283        public void CacheDecision( ISecurable securable, Permission permission, bool isGranted )
284            => ImmutableInterlocked.AddOrUpdate(
285                ref this._cache,
286                securable,
287                _ => ImmutableList.Create( (permission, isGranted) ),
288                ( _, decisions ) => decisions.Add( (permission, isGranted) ) );
289
290        public void ClearCache() => Volatile.Write( ref this._cache, _emptyCache );
291
292        public void BeginInitialization( ISecurable securable ) => ImmutableInterlocked.Update( ref this._initializing, set => set.Add( securable ) );
293
294        public void EndInitialization( ISecurable securable ) => ImmutableInterlocked.Update( ref this._initializing, set => set.Remove( securable ) );
295    }
296}
297

The role-based policy

RoleBasedAuthorizationPolicy stores the rules by class. IsGranted walks the class hierarchy of the securable. For each rule that matches the permission and a role of the subject, a Deny rule returns false immediately, and an Allow rule records the grant. PermissionEffect.None is the default value of the enumeration. A rule with this effect is ignored, so an uninitialized rule never allows anything.

A RoleBasedAuthorizationPolicyBuilder collects the rules, and its Build method creates an immutable policy, which concurrent requests can use without locks. To change the rules, an application builds a new policy and replaces the old instance. The builder rejects a rule on an interface: the policy walks base classes, never interfaces, so such a rule would never match.

1using System.Collections.Frozen;
2using System.Collections.Immutable;
3
4namespace Metalama.Samples.Authorization.RoleBased;
5
6/// <summary>
7/// A role-based <see cref="IAuthorizationPolicy"/>. Rules allow or deny a permission to the members of a role on
8/// all objects of a class (including derived classes). A subject has a permission on an object when at least one
9/// rule allows it and no rule denies it.
10/// </summary>
11/// <remarks>
12/// <para>A policy is immutable and can be used by concurrent requests. Create it with a
13/// <see cref="RoleBasedAuthorizationPolicyBuilder"/>. To change the rules, build a new policy and replace the
14/// old one, for instance in the object that provides the policy to the requests.</para>
15/// <para>Only objects implementing <see cref="IRoleBasedSecurable"/> can be secured; any permission on another
16/// object is denied.</para>
17/// </remarks>
18public sealed class RoleBasedAuthorizationPolicy : IAuthorizationPolicy
19{
20    private readonly FrozenDictionary<Type, ImmutableArray<RoleBasedAuthorizationPolicyBuilder.Rule>> _rules;
21
22    internal RoleBasedAuthorizationPolicy( FrozenDictionary<Type, ImmutableArray<RoleBasedAuthorizationPolicyBuilder.Rule>> rules )
23    {
24        this._rules = rules;
25    }
26
27    /// <inheritdoc />
28    public bool IsGranted( ISubject subject, Permission permission, ISecurable securable )
29    {
30        if ( securable is not IRoleBasedSecurable roleBasedSecurable )
31        {
32            return false;
33        }
34
35        var allowed = false;
36
37        for ( var type = securable.GetType(); type != null; type = type.BaseType )
38        {
39            if ( !this._rules.TryGetValue( type, out var rules ) )
40            {
41                continue;
42            }
43
44            foreach ( var rule in rules )
45            {
46                if ( rule.Permission != permission || !roleBasedSecurable.HasRole( subject, rule.Role ) )
47                {
48                    continue;
49                }
50
51                switch ( rule.Effect )
52                {
53                    case PermissionEffect.Deny:
54                        return false;
55
56                    case PermissionEffect.Allow:
57                        allowed = true;
58
59                        break;
60                }
61            }
62        }
63
64        return allowed;
65    }
66}
67
68/// <summary>
69/// Collects the rules of a <see cref="RoleBasedAuthorizationPolicy"/>. A builder is not thread-safe;
70/// the policy that it builds is.
71/// </summary>
72public sealed class RoleBasedAuthorizationPolicyBuilder
73{
74    private readonly Dictionary<Type, List<Rule>> _rules = [];
75
76    /// <summary>
77    /// Allows members of <paramref name="role"/> to have <paramref name="permission"/> on objects of class
78    /// <typeparamref name="T"/>. Use <c>object</c> for all classes.
79    /// </summary>
80    public RoleBasedAuthorizationPolicyBuilder Allow<T>( Permission permission, Role role )
81        => this.Add( typeof(T), permission, role, PermissionEffect.Allow );
82
83    /// <summary>
84    /// Denies <paramref name="permission"/> on objects of class <typeparamref name="T"/> to members of
85    /// <paramref name="role"/>, overriding any rule that allows it.
86    /// </summary>
87    public RoleBasedAuthorizationPolicyBuilder Deny<T>( Permission permission, Role role )
88        => this.Add( typeof(T), permission, role, PermissionEffect.Deny );
89
90    /// <summary>
91    /// Adds a rule for objects of class <paramref name="type"/>. Use this method when the rules are data, for instance
92    /// rules loaded from a database.
93    /// </summary>
94    /// <exception cref="ArgumentException"><paramref name="type"/> is an interface, or <paramref name="effect"/> is
95    /// <see cref="PermissionEffect.None"/>.</exception>
96    public RoleBasedAuthorizationPolicyBuilder Add( Type type, Permission permission, Role role, PermissionEffect effect )
97    {
98        if ( effect is not (PermissionEffect.Allow or PermissionEffect.Deny) )
99        {
100            throw new ArgumentOutOfRangeException( nameof(effect), effect, "A rule must allow or deny." );
101        }
102
103        return this.AddRule( type, new Rule( permission, role, effect ) );
104    }
105
106    public RoleBasedAuthorizationPolicy Build()
107        => new( this._rules.ToFrozenDictionary( pair => pair.Key, pair => ImmutableArray.CreateRange( pair.Value ) ) );
108
109    private RoleBasedAuthorizationPolicyBuilder AddRule( Type type, Rule rule )
110    {
111        // The policy walks the base classes of the securable, never its interfaces, so a rule on an interface
112        // would silently never match.
113        if ( type.IsInterface )
114        {
115            throw new ArgumentException( $"Rules apply to classes. '{type.Name}' is an interface.", nameof(type) );
116        }
117
118        if ( !this._rules.TryGetValue( type, out var rules ) )
119        {
120            rules = [];
121            this._rules.Add( type, rules );
122        }
123
124        rules.Add( rule );
125
126        return this;
127    }
128
129    internal sealed record Rule( Permission Permission, Role Role, PermissionEffect Effect );
130}
131

RoleBasedSecurable resolves roles. There are two kinds of roles:

  • An assigned role is stored on an object, and the children of this object inherit it. HasRole checks the assignments of the object, then delegates to HasRole of the security parent, so any implementation of IRoleBasedSecurable can be a parent.
  • A computed role is never assigned: the object computes it from its own data, and children do not inherit it. Everyone matches every subject, and Owner matches the owner of the object. An application defines more computed roles with Role.Computed and resolves them by overriding HasComputedRole. The demo defines Creator, the person who created an invoice.

Nobody can assign a role to themself. Without this rule, anyone who manages roles could give themself any right.

RoleBasedSecurable does not store anything. The Owner property is abstract, and derived classes store the role assignments through the abstract GetRoleAssignments, AddRoleAssignment and RemoveRoleAssignment methods. The framework ships no storage: the demo stores them with Entity Framework in EfRoleBasedSecurable, and the framework tests use a small in-memory implementation. The [RequiresPermission] aspect on the abstract Owner property is inherited, so every implementation of Owner is secured.

1namespace Metalama.Samples.Authorization.RoleBased;
2
3/// <summary>
4/// Base class for objects secured by <see cref="RoleBasedAuthorizationPolicy"/>. It implements role resolution:
5/// computed roles, assigned roles, and inheritance through the <see cref="SecurityParent"/> chain.
6/// </summary>
7/// <remarks>
8/// <para>The class does not store anything. Derived classes store the owner and the role assignments, for instance
9/// in memory or in a database mapped with Entity Framework.</para>
10/// <para>The members that the security infrastructure calls are excluded from default securing. Because options are
11/// inherited by overriding members, the exclusion also applies to the overrides in derived classes.</para>
12/// </remarks>
13public abstract class RoleBasedSecurable : IRoleBasedSecurable
14{
15    /// <summary>
16    /// Gets or sets the owner of the object, who has the built-in <see cref="Role.Owner"/> role on it. Nobody can make
17    /// themself the owner, except while they create the object.
18    /// </summary>
19    [RequiresPermission( PermissionNames.Read, PermissionNames.ChangeOwnership )]
20    public ISubject? Owner
21    {
22        get => this.GetOwner();
23        set
24        {
25            // Without this rule, anyone who can change the owner could give themself the rights of the owner. The
26            // creator of an object is allowed to become its owner while the object is initialized.
27            if ( value != null
28                 && !SecurityContext.IsSystem
29                 && !SecurityContext.IsInitializing( this )
30                 && value.Equals( SecurityContext.Current?.Subject )
31                 && !value.Equals( this.GetOwner() ) )
32            {
33                AccessGuard.Deny(
34                    this,
35                    PermissionNames.ChangeOwnership,
36                    $"{nameof(RoleBasedSecurable)}.{nameof(this.Owner)}",
37                    $"'{value.Name}' cannot make themself the owner" );
38            }
39
40            this.SetOwner( value );
41        }
42    }
43
44    /// <summary>
45    /// Returns the stored owner of the object.
46    /// </summary>
47    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
48    protected abstract ISubject? GetOwner();
49
50    /// <summary>
51    /// Stores the owner of the object. <see cref="Owner"/> has checked the permission.
52    /// </summary>
53    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
54    protected abstract void SetOwner( ISubject? owner );
55
56    /// <summary>
57    /// Gets the roles assigned on this object. Assignments inherited from the security parent are not included.
58    /// </summary>
59    [RequiresPermission( PermissionNames.Read )]
60    public IReadOnlyList<RoleAssignment> RoleAssignments => [.. this.GetRoleAssignments()];
61
62    /// <summary>
63    /// Gets the object from which this object inherits assigned roles, or <c>null</c>.
64    /// </summary>
65    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
66    protected abstract IRoleBasedSecurable? SecurityParent { get; }
67
68    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
69    IRoleBasedSecurable? IRoleBasedSecurable.SecurityParent => this.SecurityParent;
70
71    /// <summary>
72    /// Returns the role assignments stored on this object.
73    /// </summary>
74    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
75    protected abstract IEnumerable<RoleAssignment> GetRoleAssignments();
76
77    /// <summary>
78    /// Stores a new role assignment on this object. The assignment is not stored yet.
79    /// </summary>
80    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
81    protected abstract void AddRoleAssignment( RoleAssignment assignment );
82
83    /// <summary>
84    /// Removes a role assignment stored on this object.
85    /// </summary>
86    /// <returns><c>true</c> if the assignment existed, otherwise <c>false</c>.</returns>
87    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
88    protected abstract bool RemoveRoleAssignment( RoleAssignment assignment );
89
90    /// <summary>
91    /// Determines whether <paramref name="subject"/> has a computed role defined by the application
92    /// (see <see cref="Role.Computed"/>). The default implementation returns <c>false</c>.
93    /// </summary>
94    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
95    protected virtual bool HasComputedRole( ISubject subject, Role role ) => false;
96
97    /// <summary>
98    /// Assigns a role to a subject on this object. Nobody can assign a role to themself.
99    /// </summary>
100    [RequiresPermission( PermissionNames.ManageRoles )]
101    public void AssignRole( ISubject subject, Role role )
102    {
103        if ( role.IsComputed )
104        {
105            throw new ArgumentException( $"The computed role '{role}' cannot be assigned.", nameof(role) );
106        }
107
108        // Without this rule, anyone who manages roles could give themself any right.
109        if ( !SecurityContext.IsSystem && subject.Equals( SecurityContext.Current?.Subject ) )
110        {
111            AccessGuard.Deny(
112                this,
113                PermissionNames.ManageRoles,
114                $"{nameof(RoleBasedSecurable)}.{nameof(this.AssignRole)}",
115                $"'{subject.Name}' cannot assign a role to themself" );
116        }
117
118        var assignment = new RoleAssignment( subject, role );
119
120        if ( !this.GetRoleAssignments().Contains( assignment ) )
121        {
122            this.AddRoleAssignment( assignment );
123        }
124    }
125
126    /// <summary>
127    /// Revokes a role assigned to a subject on this object. Nobody can revoke a role from themself.
128    /// </summary>
129    /// <returns><c>true</c> if the assignment existed, otherwise <c>false</c>.</returns>
130    [RequiresPermission( PermissionNames.ManageRoles )]
131    public bool RevokeRole( ISubject subject, Role role )
132    {
133        // A role can carry Deny rules, so revoking it from oneself can increase one's rights.
134        if ( !SecurityContext.IsSystem && subject.Equals( SecurityContext.Current?.Subject ) )
135        {
136            AccessGuard.Deny(
137                this,
138                PermissionNames.ManageRoles,
139                $"{nameof(RoleBasedSecurable)}.{nameof(this.RevokeRole)}",
140                $"'{subject.Name}' cannot revoke a role from themself" );
141        }
142
143        return this.RemoveRoleAssignment( new RoleAssignment( subject, role ) );
144    }
145
146    /// <inheritdoc />
147    /// <remarks>The policy calls this method in a system scope, so it is excluded from default securing.</remarks>
148    [AuthorizationOptions( IsDefaultSecuringEnabled = false )]
149    public bool HasRole( ISubject subject, Role role )
150    {
151        // Computed roles apply to the object itself and are not inherited by children.
152        if ( role.IsComputed )
153        {
154            if ( role == Role.Everyone )
155            {
156                return true;
157            }
158
159            if ( role == Role.Owner )
160            {
161                return subject.Equals( this.GetOwner() );
162            }
163
164            return this.HasComputedRole( subject, role );
165        }
166
167        if ( this.GetRoleAssignments().Contains( new RoleAssignment( subject, role ) ) )
168        {
169            return true;
170        }
171
172        // Delegating to the parent supports any implementation of IRoleBasedSecurable in the chain.
173        return this.SecurityParent?.HasRole( subject, role ) == true;
174    }
175}
176

The demo: invoicing at Contoso

The company Contoso has three departments: Sales, Marketing and Engineering. Departments manage customers, issue invoices and have a yearly budget. An invoice moves through the statuses Draft, Approved, Sent, Paid and Voided.

The security model from the business point of view

The security model of the demo describes who may do what, in the words of the business, without reference to the code: the positions, and the rules on invoices, customers, budgets and access rights. Each rule has an identifier, such as INV-4, which also appears in the permission matrix of the tests.

How the demo implements the model

The positions are roles assigned on the company or on a department, and inherited by the objects below. The owner, the creator and the "every user" cases are computed roles: Owner and Everyone are built in, and the demo defines Creator:

Role Scope Responsibilities
Department manager department Approves the invoices of the department, maintains customers and their credit terms, sees sensitive customer data and the department budget, moves invoices between departments they manage, assigns roles in the department.
Department assistant department Prepares and sends invoices, maintains customer contact details.
Accountant company Records payments, voids invoices, manages budgets. Does not edit invoice content.
Auditor company Reads everything, including sensitive data. Every change is denied.
Administrator company Assigns and revokes roles everywhere. Sensitive data is denied.
Owner (computed) object Reads and edits the invoice they own, but cannot approve it.
Creator (computed) invoice Cannot approve the invoice they created, even after giving it to another owner.
Everyone (computed) all Reads the company directory.

The sample data contains one person per typical combination of positions:

User Positions
Alice Administrator
Ada Accountant
Otto Auditor
Grace Auditor, and manager of Marketing
Mia Manager of Sales and of Marketing, owner of the marketing invoice
Sam Assistant in Sales, owner of the sales invoice
Erin Manager of Engineering
Tom Assistant in Engineering, owner of the engineering invoice
Vera No position

The policy expresses three common security principles with Deny rules:

  • Four-eyes principle: neither the owner nor the creator of an invoice can approve it, even as a department manager. The creator rule is needed because a manager can change the owner of an invoice, but nobody can change its creator.
  • Segregation of duties: an auditor cannot change anything, even with a manager role in a department.
  • Least privilege: administrators manage access but cannot read sensitive data.

The rules are data, not code. They are stored in the database, where an administrator can change them, and DemoPolicy contains only the seed data of the database:

1using Metalama.Samples.Authorization.Demo.Data;
2using Metalama.Samples.Authorization.Demo.Finance;
3using Metalama.Samples.Authorization.Demo.Sales;
4using Metalama.Samples.Authorization.RoleBased;
5using static Metalama.Samples.Authorization.Demo.DemoPermissions;
6using static Metalama.Samples.Authorization.Demo.DemoRoles;
7using static Metalama.Samples.Authorization.PermissionNames;
8
9namespace Metalama.Samples.Authorization.Demo;
10
11/// <summary>
12/// The role-permission rules of the invoicing domain, as the seed data of the database. An administrator can then
13/// change them in the database, which is why they live in a run-time policy and not in build-time options.
14/// <see cref="DatabaseAuthorizationPolicy"/> loads the rules from the database.
15/// </summary>
16public static class DemoPolicy
17{
18    /// <summary>
19    /// Creates the records of the rules. Each database gets new records, because Entity Framework assigns their keys.
20    /// </summary>
21    public static IReadOnlyList<PermissionRuleRecord> CreateSeedRules()
22        =>
23        [
24            // The company directory is public.
25            Allow<Company>( Read, Role.Everyone ),
26            Allow<Department>( Read, Role.Everyone ),
27
28            // The owner of an invoice can read and edit it. Neither the owner nor the creator can approve it:
29            // the four-eyes principle. The creator rule matters because a manager can give an invoice to
30            // another owner, but cannot change who created it.
31            Allow<Invoice>( Read, Role.Owner ),
32            Allow<Invoice>( Write, Role.Owner ),
33            Deny<Invoice>( Approve, Role.Owner ),
34            Deny<Invoice>( Approve, Creator ),
35
36            // Department assistants prepare and send invoices, and maintain customer contact details.
37            Allow<Department>( CreateInvoice, DepartmentAssistant ),
38            Allow<Invoice>( Read, DepartmentAssistant ),
39            Allow<Invoice>( Write, DepartmentAssistant ),
40            Allow<Invoice>( Send, DepartmentAssistant ),
41            Allow<Customer>( Read, DepartmentAssistant ),
42            Allow<Customer>( Write, DepartmentAssistant ),
43
44            // Department assistants see the status of the budget, but not its amounts, which require ReadSensitive.
45            Allow<Budget>( Read, DepartmentAssistant ),
46
47            // Department managers do everything assistants do, approve invoices and credit terms, see sensitive
48            // customer data and the budget of their department, move invoices between departments they manage,
49            // and staff their department.
50            Allow<object>( Read, DepartmentManager ),
51            Allow<Department>( CreateInvoice, DepartmentManager ),
52            Allow<Department>( ChangeOwnership, DepartmentManager ),
53            Allow<Department>( ManageRoles, DepartmentManager ),
54            Allow<Invoice>( Write, DepartmentManager ),
55            Allow<Invoice>( Approve, DepartmentManager ),
56            Allow<Invoice>( Send, DepartmentManager ),
57            Allow<Invoice>( ChangeOwnership, DepartmentManager ),
58            Allow<Customer>( Write, DepartmentManager ),
59            Allow<Customer>( Approve, DepartmentManager ),
60            Allow<Customer>( ChangeOwnership, DepartmentManager ),
61            Allow<Customer>( ReadSensitive, DepartmentManager ),
62            Allow<Budget>( ReadSensitive, DepartmentManager ),
63
64            // Accountants work company-wide on money, not on invoice content.
65            Allow<object>( Read, Accountant ),
66            Allow<object>( ReadSensitive, Accountant ),
67            Allow<Invoice>( RecordPayment, Accountant ),
68            Allow<Invoice>( DemoPermissions.Void, Accountant ),
69            Allow<Budget>( Write, Accountant ),
70
71            // Auditors see everything and change nothing. Deny wins over any role the auditor also has.
72            Allow<object>( Read, Auditor ),
73            Allow<object>( ReadSensitive, Auditor ),
74            Deny<object>( CreateInvoice, Auditor ),
75            Deny<object>( Write, Auditor ),
76            Deny<object>( Approve, Auditor ),
77            Deny<object>( Send, Auditor ),
78            Deny<object>( RecordPayment, Auditor ),
79            Deny<object>( DemoPermissions.Void, Auditor ),
80            Deny<object>( ChangeOwnership, Auditor ),
81            Deny<object>( ManageRoles, Auditor ),
82
83            // Administrators manage roles but are kept away from sensitive data.
84            Allow<object>( ManageRoles, Administrator ),
85            Deny<object>( ReadSensitive, Administrator )
86        ];
87
88    private static PermissionRuleRecord Allow<T>( string permission, Role role ) => Rule<T>( permission, role, PermissionEffect.Allow );
89
90    private static PermissionRuleRecord Deny<T>( string permission, Role role ) => Rule<T>( permission, role, PermissionEffect.Deny );
91
92    private static PermissionRuleRecord Rule<T>( string permission, Role role, PermissionEffect effect )
93        => new( typeof(T) == typeof(object) ? PermissionRuleRecord.AnyType : typeof(T).Name, permission, role.Name, effect );
94}
95

Each rule is a PermissionRuleRecord, which stores the class, the permission and the role by name, and the effect as text. The rule on all classes stores * as its class.

DatabaseAuthorizationPolicy is the policy of the application. It loads the rules from the database with ReloadAsync, and builds an immutable RoleBasedAuthorizationPolicy with the non-generic RoleBasedAuthorizationPolicyBuilder.Add method. After an administrator changes the rules, a new call to ReloadAsync replaces the policy, and the change applies to the next check of every request. The policy is fail-closed in two ways:

  • Before the rules are loaded, every check is denied.
  • A rule on an unknown class, an unknown permission or an unknown role makes the reload fail, and the previous rules stay in force. A mistyped name would match no object, no check or no subject, so loading the rule could silently remove a Deny rule. For instance, a rule stored as "Deny Write to Auditer" would let the auditors write.

A role is resolved from its name by DemoRoles.FromName, which knows the roles of the application, and a permission must be a constant of PermissionNames or DemoPermissions. A new role therefore requires a change of DemoRoles.

1using Metalama.Samples.Authorization.RoleBased;
2using Microsoft.EntityFrameworkCore;
3
4namespace Metalama.Samples.Authorization.Demo.Data;
5
6/// <summary>
7/// The authorization policy of the application, whose rules are stored in the database (see
8/// <see cref="PermissionRuleRecord"/>). The rules are loaded into an immutable <see cref="RoleBasedAuthorizationPolicy"/>.
9/// After an administrator changes the rules, <see cref="ReloadAsync"/> replaces this policy, and the change applies
10/// to the next checks of every request.
11/// </summary>
12/// <remarks>
13/// Before the rules are loaded, every check is denied: a policy that could not be loaded must not allow anything.
14/// </remarks>
15public sealed class DatabaseAuthorizationPolicy : IAuthorizationPolicy
16{
17    // The classes to which a rule can apply, by the name that the database stores.
18    private static readonly Dictionary<string, Type> _objectTypes =
19        typeof(Entity).Assembly.GetTypes()
20            .Where( type => type.IsSubclassOf( typeof(Entity) ) )
21            .ToDictionary( type => type.Name );
22
23    // The permissions that the code checks: the constants of PermissionNames and DemoPermissions.
24    private static readonly HashSet<string> _permissions =
25        new[] { typeof(PermissionNames), typeof(DemoPermissions) }
26            .SelectMany( type => type.GetFields() )
27            .Where( field => field.IsLiteral && field.FieldType == typeof(string) )
28            .Select( field => (string) field.GetRawConstantValue()! )
29            .ToHashSet();
30
31    private volatile RoleBasedAuthorizationPolicy? _policy;
32
33    /// <summary>
34    /// Occurs after the rules have been reloaded, for instance to invalidate a cache of decisions.
35    /// </summary>
36    public event Action? Reloaded;
37
38    public bool IsGranted( ISubject subject, Permission permission, ISecurable securable )
39        => this._policy?.IsGranted( subject, permission, securable ) ?? false;
40
41    /// <summary>
42    /// Loads the rules from the database and replaces the current policy.
43    /// </summary>
44    /// <exception cref="InvalidOperationException">A rule names an unknown class, permission or role. The current
45    /// policy is not changed.</exception>
46    public async Task ReloadAsync( InvoicingDbContext db, CancellationToken cancellationToken = default )
47    {
48        var rules = await db.PermissionRules.AsNoTracking().OrderBy( r => r.Id ).ToListAsync( cancellationToken );
49        var builder = new RoleBasedAuthorizationPolicyBuilder();
50
51        foreach ( var rule in rules )
52        {
53            builder.Add( GetObjectType( rule ), GetPermission( rule ), GetRole( rule ), rule.Effect );
54        }
55
56        this._policy = builder.Build();
57        this.Reloaded?.Invoke();
58    }
59
60    // A rule on an unknown class, permission or role is a configuration error. Loading it could silently remove a
61    // Deny rule: a mistyped name matches no check, or a role that nobody has.
62    private static Permission GetPermission( PermissionRuleRecord rule )
63        => _permissions.Contains( rule.Permission )
64            ? rule.Permission
65            : throw new InvalidOperationException( $"The rule '{rule}' names the unknown permission '{rule.Permission}'." );
66
67    private static Role GetRole( PermissionRuleRecord rule )
68        => DemoRoles.FromName( rule.Role ) ?? throw new InvalidOperationException( $"The rule '{rule}' names the unknown role '{rule.Role}'." );
69
70    private static Type GetObjectType( PermissionRuleRecord rule )
71        => rule.ObjectType == PermissionRuleRecord.AnyType
72            ? typeof(object)
73            : _objectTypes.GetValueOrDefault( rule.ObjectType )
74              ?? throw new InvalidOperationException( $"The rule '{rule}' applies to the unknown class '{rule.ObjectType}'." );
75}
76

The rule records are not securable objects in this example: no user operation of the demo changes them. In a real application, editing the rules is the most privileged operation of all, and it would be protected and audited like the role assignments.

Authorization is a different concern from business rules. The policy answers "may this user do this?". The Invoice methods enforce rules such as "an approved invoice cannot be edited", and they throw InvalidOperationException, not AccessDeniedException.

Moving an invoice to another department needs a parameter check. Invoice.TransferTo requires ChangeOwnership on the invoice and on the destination department. Without the second check, a user could move an invoice into a department where they have more rights.

The program runs each scenario as a separate request of a user, through the application services, and prints the outcome. The auditor writes the denials, and the grants of the permissions that change rights, to the log. The messages name objects only by their label. Here is an excerpt:

Approving: the four-eyes principle and segregation of duties
  GOOD Mia approves Sam's invoice: succeeded
  GOOD Mia adds a line to the approved invoice (business rule): failed: Invoice SAL-0001 is Approved, expected Draft.
warn: Metalama.Samples.Authorization.Demo.Services.LoggingAccessAuditor[277457468] Mia was denied Approve on Invoice.Approve of Invoice #2.
  GOOD Mia approves her own marketing invoice: was denied: Cannot execute Invoice.Approve: 'Mia' does not have the Approve permission on Invoice #2.
info: Metalama.Samples.Authorization.Demo.Services.LoggingAccessAuditor[1113576134] Mia was granted ChangeOwnership on Entity.Owner of Invoice #2.
  GOOD Mia gives her marketing invoice to Grace: succeeded
warn: Metalama.Samples.Authorization.Demo.Services.LoggingAccessAuditor[277457468] Mia was denied Approve on Invoice.Approve of Invoice #2.
  GOOD Mia approves the invoice that she created, now owned by Grace: was denied: Cannot execute Invoice.Approve: 'Mia' does not have the Approve permission on Invoice #2.
...
Moving an invoice: checking the parameter
  GOOD Mia creates a sales invoice: succeeded
info: Metalama.Samples.Authorization.Demo.Services.LoggingAccessAuditor[1113576134] Mia was granted ChangeOwnership on Invoice.TransferTo(target) of Department #2.
info: Metalama.Samples.Authorization.Demo.Services.LoggingAccessAuditor[1113576134] Mia was granted ChangeOwnership on Invoice.TransferTo of Invoice #4.
  GOOD Mia moves it to Marketing, which she also manages: succeeded
warn: Metalama.Samples.Authorization.Demo.Services.LoggingAccessAuditor[277457468] Mia was denied ChangeOwnership on Invoice.TransferTo(target) of Department #3.
  GOOD Mia moves it to Engineering: was denied: Cannot execute Invoice.TransferTo(target): 'Mia' does not have the ChangeOwnership permission on Department #3.

Persistence and dependency injection

The demo is structured like a real application. The domain objects are stored with Entity Framework Core (code first) in an in-memory SQLite database, and the application services are registered in a dependency injection container:

1using Metalama.Samples.Authorization.Demo.Data;
2using Metalama.Samples.Authorization.Demo.Sales;
3using Microsoft.EntityFrameworkCore;
4using Microsoft.Extensions.Caching.Memory;
5using Microsoft.Extensions.DependencyInjection;
6
7namespace Metalama.Samples.Authorization.Demo.Services;
8
9public static class ServiceCollectionExtensions
10{
11    /// <summary>
12    /// Registers the invoicing application: the database, the security infrastructure and the application services.
13    /// </summary>
14    public static IServiceCollection AddInvoicingApplication( this IServiceCollection services )
15    {
16        // Each request gets its own database context and connection. The database is a named in-memory SQLite
17        // database, shared by all the connections and kept alive by InMemoryDatabase.
18        services.AddSingleton<InMemoryDatabase>();
19
20        // Split queries load each collection navigation, such as role assignments, with its own SQL query.
21        // Entities that Entity Framework loads are created in a system scope.
22        services.AddDbContext<InvoicingDbContext>(
23            ( provider, options ) => options
24                .UseSqlite(
25                    provider.GetRequiredService<InMemoryDatabase>().ConnectionString,
26                    sqlite => sqlite.UseQuerySplittingBehavior( QuerySplittingBehavior.SplitQuery ) )
27                .AddInterceptors( SystemScopeMaterializationInterceptor.Instance, PermissionCacheInvalidationInterceptor.Instance ) );
28
29        // Security infrastructure. The policy loads its rules from the database, see DemoData. Its decisions are
30        // cached for all requests, behind the cache of each execution context, and invalidated after each change of
31        // the data that the policy reads.
32        services.AddMemoryCache();
33        services.AddSingleton<DatabaseAuthorizationPolicy>();
34
35        services.AddSingleton(
36            provider =>
37            {
38                var policy = provider.GetRequiredService<DatabaseAuthorizationPolicy>();
39
40                var cachingPolicy = new CachingAuthorizationPolicy(
41                    policy,
42                    provider.GetRequiredService<IMemoryCache>(),
43                    PermissionCacheKeys.GetKey,
44                    PermissionCacheKeys.GetSecurityParents );
45
46                policy.Reloaded += cachingPolicy.InvalidateAll;
47
48                return cachingPolicy;
49            } );
50
51        services.AddSingleton<IAuthorizationPolicy>( provider => provider.GetRequiredService<CachingAuthorizationPolicy>() );
52        services.AddSingleton<IAccessAuditor, LoggingAccessAuditor>();
53        services.AddSingleton<RequestRunner>();
54
55        // Infrastructure services used by the domain.
56        services.AddSingleton<OutboxInvoiceSender>();
57        services.AddSingleton<IInvoiceSender>( provider => provider.GetRequiredService<OutboxInvoiceSender>() );
58
59        // Application services, one instance per request.
60        services.AddScoped<InvoiceService>();
61        services.AddScoped<CustomerService>();
62        services.AddScoped<BudgetService>();
63        services.AddScoped<StaffService>();
64
65        return services;
66    }
67}
68

The services, such as InvoiceService, load the objects, call the domain methods, and save the changes. They contain no permission check: the domain objects enforce them, whichever code calls them. A service checks a permission explicitly only for an operation that has no secured object. For instance, StaffService.CreateUserAsync calls AccessGuard.Demand on the company, because a user account is not a securable object.

RequestRunner plays the role of the authentication middleware of a web application. It creates a dependency injection scope, which has its own database context, loads the user, and sets SecurityContext.Current while the request runs:

1using Metalama.Samples.Authorization.Demo.Data;
2using Microsoft.EntityFrameworkCore;
3using Microsoft.Extensions.DependencyInjection;
4
5namespace Metalama.Samples.Authorization.Demo.Services;
6
7/// <summary>
8/// Runs a request on behalf of a user: it creates a dependency injection scope with its own database context,
9/// loads the user, and sets <see cref="SecurityContext.Current"/> while the request runs. In a web application,
10/// a middleware plays this role after authentication.
11/// </summary>
12public sealed class RequestRunner( IServiceScopeFactory scopeFactory, IAuthorizationPolicy policy, IAccessAuditor auditor )
13{
14    public async Task<TResult> RunAsync<TResult>(
15        string userName,
16        Func<IServiceProvider, Task<TResult>> request,
17        CancellationToken cancellationToken = default )
18    {
19        await using var scope = scopeFactory.CreateAsyncScope();
20
21        var db = scope.ServiceProvider.GetRequiredService<InvoicingDbContext>();
22        var user = await db.Users.SingleAsync( u => u.Name == userName, cancellationToken );
23
24        // SecurityContext.Current is stored in an AsyncLocal, so it must be set here, in the method that awaits
25        // the request. A value set inside an awaited method is not visible to its caller.
26        using ( SecurityContext.Use( new UserSecurityContext( user, policy, auditor ) ) )
27        {
28            return await request( scope.ServiceProvider );
29        }
30    }
31
32    public Task RunAsync( string userName, Func<IServiceProvider, Task> request, CancellationToken cancellationToken = default )
33        => this.RunAsync(
34            userName,
35            async services =>
36            {
37                await request( services );
38
39                return true;
40            },
41            cancellationToken );
42}
43

SecurityContext.Current is stored in an AsyncLocal<T>. A value set inside an awaited method is not visible to the caller of this method, so the context must be set in the method that awaits the request, as RequestRunner does.

Entity Framework and the secured members

Entity Framework reads and writes every mapped property when it loads an object, detects changes, and saves them. These operations belong to the infrastructure, not to the user, and they often run without a security context. They must not go through the secured accessors. The demo follows these rules:

  • The model uses PropertyAccessMode.Field. Entity Framework reads and writes the backing fields directly, including the fields that Metalama generates when it overrides an auto-property.
  • SystemScopeMaterializationInterceptor creates each entity in a system scope, with its parameterless constructor, which can be private, and records its load for the shared cache of decisions. Constructors can therefore set secured properties, for instance by chaining to the constructor that the application uses. The scope is closed by a using statement, so it cannot remain open after a failure.
  • Entity Framework fills collections through their backing field, before the first access through the property, so a SecurableList<T> has no owner yet and checks nothing. A primitive collection, such as Invoice.Notes, is assigned by Entity Framework when it loads the object, so a value converter recreates a SecurableList<T>.
  • EfRoleBasedSecurable, the base class of Entity, stores the owner as a User navigation and the role assignments as an owned collection of RoleAssignmentRecord. ConfigureRoleBasedStorage maps them for each entity class. These storage members are excluded from default securing with [AuthorizationOptions( IsDefaultSecuringEnabled = false )]. The public Owner and RoleAssignments members are the secured views of these members, and the model ignores them.
  • The navigations that the security model needs are loaded automatically with AutoInclude: the parent chain of each object, its owner, and its role assignments. Otherwise, a role inherited from a parent that is not loaded would be missed. The policy would then deny the access, which is safe but wrong.
  • The state of an entity is stored in properties, not in fields. Metalama secures a field by turning it into a property, and moves its value to a new field, for instance _amount1 for _amount, which Entity Framework cannot discover by convention. A property with a private setter, such as Budget.Amount, has a backing field that Entity Framework discovers, _amount, and it is secured in the same way.
  • By convention, Entity Framework maps only the properties that have a setter. The get-only properties that expose the state of an object, such as Invoice.Status, are mapped explicitly. A test checks that every field of every entity is mapped.

1using Metalama.Samples.Authorization.Demo.Finance;
2using Metalama.Samples.Authorization.Demo.Sales;
3using Microsoft.EntityFrameworkCore;
4using Microsoft.EntityFrameworkCore.ChangeTracking;
5using Microsoft.EntityFrameworkCore.Metadata.Builders;
6using System.Text.Json;
7
8namespace Metalama.Samples.Authorization.Demo.Data;
9
10/// <summary>
11/// The Entity Framework model of the invoicing application (code first).
12/// </summary>
13public sealed class InvoicingDbContext( DbContextOptions<InvoicingDbContext> options, CachingAuthorizationPolicy cachingPolicy )
14    : DbContext( options )
15{
16    /// <summary>
17    /// Gets the shared cache of decisions, which the interceptors of the context maintain. The interceptors are
18    /// single instances, because Entity Framework creates an internal service provider for each set of interceptor
19    /// instances, so they find the cache through the context.
20    /// </summary>
21    internal CachingAuthorizationPolicy CachingPolicy { get; } = cachingPolicy;
22
23    public DbSet<User> Users => this.Set<User>();
24
25    public DbSet<Company> Companies => this.Set<Company>();
26
27    public DbSet<Department> Departments => this.Set<Department>();
28
29    public DbSet<Customer> Customers => this.Set<Customer>();
30
31    public DbSet<Invoice> Invoices => this.Set<Invoice>();
32
33    public DbSet<Budget> Budgets => this.Set<Budget>();
34
35    public DbSet<PermissionRuleRecord> PermissionRules => this.Set<PermissionRuleRecord>();
36
37    protected override void OnModelCreating( ModelBuilder modelBuilder )
38    {
39        // Entity Framework must never go through the secured accessors. Loading an object, detecting changes and
40        // saving are infrastructure operations, not user operations: they often run without a security context,
41        // and they read and write every property. Field access makes Entity Framework read and write the backing
42        // fields directly, including the fields that Metalama generates for the overridden auto-properties.
43        modelBuilder.UsePropertyAccessMode( PropertyAccessMode.Field );
44
45        // By convention, Entity Framework maps only the properties that have a setter. The get-only properties that
46        // expose the state of an object, such as Invoice.Status, must be mapped explicitly, or they are not saved.
47
48        modelBuilder.Entity<User>( user => user.HasIndex( u => u.Name ).IsUnique() );
49
50        // The rules of the policy are stored by name, and the effect as text, so that an administrator can read them.
51        modelBuilder.Entity<PermissionRuleRecord>( rule => rule.Property( r => r.Effect ).HasConversion<string>() );
52
53        ConfigureSecurable<Company>( modelBuilder );
54
55        ConfigureSecurable<Department>( modelBuilder )
56            .Entity<Department>(
57                department =>
58                {
59                    department.Property( d => d.Code );
60                    department.HasIndex( d => d.Code ).IsUnique();
61                    department.HasOne( d => d.Company ).WithMany();
62                    department.Property<int>( nameof(Department.LastInvoiceNumber) );
63                } );
64
65        ConfigureSecurable<Customer>( modelBuilder )
66            .Entity<Customer>( customer => customer.HasOne( c => c.Department ).WithMany() );
67
68        ConfigureSecurable<Invoice>( modelBuilder )
69            .Entity<Invoice>(
70                invoice =>
71                {
72                    invoice.Property( i => i.Number );
73                    invoice.Property( i => i.Status );
74                    invoice.Property( i => i.AmountPaid );
75                    invoice.HasIndex( i => i.Number ).IsUnique();
76                    invoice.HasOne( i => i.Department ).WithMany();
77                    invoice.HasOne( i => i.Customer ).WithMany();
78                    invoice.HasOne<User>( "CreatorUser" ).WithMany();
79                    invoice.Ignore( i => i.Creator );
80                    invoice.OwnsMany( i => i.Lines, line => UseSingleColumnKey( line ) );
81                    // Entity Framework assigns a new collection when it loads a primitive collection. The converter
82                    // makes this collection a securable list, as the constructor does.
83                    invoice.Property( i => i.Notes )
84                        .HasConversion(
85                            notes => JsonSerializer.Serialize( notes, (JsonSerializerOptions?) null ),
86                            json => DeserializeNotes( json ),
87                            new ValueComparer<IList<string>>(
88                                ( left, right ) => left!.SequenceEqual( right! ),
89                                notes => notes.Aggregate( 0, ( hash, note ) => HashCode.Combine( hash, note ) ),
90                                notes => CopyNotes( notes ) ) );
91                } );
92
93        ConfigureSecurable<Budget>( modelBuilder )
94            .Entity<Budget>(
95                budget =>
96                {
97                    budget.HasOne( b => b.Department ).WithMany();
98                    budget.Property( b => b.FiscalYear );
99                    budget.Property( b => b.Amount );
100                    budget.Property( b => b.Spent );
101                } );
102
103        // The security model walks the parent chain and compares owners and role assignments. Loading these
104        // navigations automatically means that a role inherited from an unloaded parent is never missed.
105        modelBuilder.Entity<Department>().Navigation( d => d.Company ).AutoInclude();
106        modelBuilder.Entity<Customer>().Navigation( c => c.Department ).AutoInclude();
107        modelBuilder.Entity<Invoice>().Navigation( i => i.Department ).AutoInclude();
108        modelBuilder.Entity<Invoice>().Navigation( i => i.Customer ).AutoInclude();
109        modelBuilder.Entity<Invoice>().Navigation( "CreatorUser" ).AutoInclude();
110        modelBuilder.Entity<Budget>().Navigation( b => b.Department ).AutoInclude();
111    }
112
113    // The lists are created in methods, and not in the expression trees of the converter: Metalama rewrites the
114    // construction of a securable object to call its Initialize method, which an expression tree cannot contain.
115    private static SecurableList<string> DeserializeNotes( string json )
116        => new( JsonSerializer.Deserialize<List<string>>( json, (JsonSerializerOptions?) null ) ?? [] );
117
118    private static SecurableList<string> CopyNotes( IList<string> notes ) => new( notes );
119
120    // SQLite cannot generate the composite key that Entity Framework gives to owned collections by default.
121    private static void UseSingleColumnKey<TOwner, TOwned>( OwnedNavigationBuilder<TOwner, TOwned> owned )
122        where TOwner : class
123        where TOwned : class
124    {
125        owned.Property<int>( "Id" );
126        owned.HasKey( "Id" );
127    }
128
129    // Maps an entity: the storage of its owner and role assignments, by EfRoleBasedSecurable.
130    private static ModelBuilder ConfigureSecurable<T>( ModelBuilder modelBuilder )
131        where T : Entity
132        => modelBuilder.Entity<T>(
133            entity =>
134            {
135                entity.ConfigureRoleBasedStorage();
136                entity.Ignore( e => e.SecurityLabel );
137            } );
138}
139

A connection to an in-memory SQLite database cannot be used by concurrent requests. InMemoryDatabase keeps a named, shared-cache in-memory database open, and each database context opens its own connection to it.

Tests

Aspect tests

The aspect tests in Metalama.Samples.Authorization.Tests verify the generated code. They cover each kind of target (methods, async methods, properties, init accessors, fields, explicit interface implementations, method and constructor parameters), default permissions, inheritance by overrides and by abstract members, each level of options, the eligibility errors, the securable lists of collection properties, the warnings of the validator, and the grants of [GrantsPermissions]: their position inside the permission check, on members and on parameters, their inheritance by overrides and by implementations of an interface, their eligibility, and the error AUTH004 on a member or a parameter without a check.

SecurableInterface shows that implementing ISecurable, directly or through a derived interface, is enough. Two tests, Demo_SalesConventions and Demo_FinanceConventions, compile the real configuration of the demo application with // @Include directives: the Entity base class and its storage types, the project fabric, the namespace fabric and the permission names. They show the code that this configuration generates. For instance, in the Finance namespace:

Source Code
1using Metalama.Samples.Authorization.RoleBased;
2
3namespace Metalama.Samples.Authorization.Demo.Finance;
4

5public sealed class Forecast : Entity
6{
7    // Reading requires ReadSensitive, from the namespace fabric. Writing keeps the default.
8    public decimal Amount { get; set; }
9
10    // Requires Write, the default permission of methods.


11    public void Revise( decimal amount ) => this.Amount = amount;
12













13    protected override IRoleBasedSecurable? SecurityParent => null;
14}
15
Transformed Code
1using Metalama.Framework.RunTime.Initialization;
2using Metalama.Samples.Authorization.RoleBased;
3
4namespace Metalama.Samples.Authorization.Demo.Finance;
5
6public sealed class Forecast : Entity
7{
8    private decimal _amount;
9
10    // Reading requires ReadSensitive, from the namespace fabric. Writing keeps the default.
11    public decimal Amount
12    {
13        get
14        {
15            AccessGuard.Demand(this, "ReadSensitive", "Forecast.Amount", AccessKind.Read);
16            return _amount;
17        }
18
19        set
20        {
21            AccessGuard.Demand(this, "Write", "Forecast.Amount", AccessKind.Write);
22            _amount = value;
23        }
24    }
25
26    // Requires Write, the default permission of methods.
27    public void Revise(decimal amount)
28    {
29        AccessGuard.Demand(this, "Write", "Forecast.Revise", AccessKind.Execute);
30        this.Amount = amount;
31    }
32
33    protected override IRoleBasedSecurable? SecurityParent => null;
34
35    public Forecast(InitializationContext context) : base(context.Descend(InitializationSlot.OnConstructed))
36    {
37        SecurityContext.BeginInitialization(this);
38        this.OnConstructed(context);
39    }
40
41    public Forecast() : this(context: default)
42    {
43    }
44
45    public override void Initialize(InitializationContext context = default)
46    {
47        base.Initialize(context);
48        SecurityContext.EndInitialization(this);
49    }
50
51    protected override void OnConstructed(InitializationContext context = default)
52    {
53        base.OnConstructed(context);
54        SecurityContext.EndConstruction(this);
55    }
56}
57

Unit tests of the framework

Metalama.Samples.Authorization.UnitTests tests the framework on small test types, independently of the demo:

  • SecurityContextTests: fail-closed behavior, system scopes, Use that ends a system scope, and context isolation across await and concurrent tasks.
  • InitializationTests: unlimited rights on an object while its constructors and its object initializer run, the checks on other objects, the checks after the initialization, a constructor without a context that fails closed, an object created by reflection that is unusable until CompleteInitialization, and the parameterless constructor of a class that implements ISecurable directly.
  • NestedAccessTests: the permission of a member does not grant the permissions of the members that it accesses, including private state.
  • PermissionCacheTests: the cache of an execution context, shared by the async methods and the tasks of the context, the denials cached and still audited, the grant that breaks the execution context, a change of rights that applies to the next execution context, and a change of owner that clears the cache at once.
  • CachingAuthorizationPolicyTests: the cache shared by all execution contexts, the objects without a key or without a recorded load, the objects loaded before an invalidation, the invalidation of an object and of its descendants, and an invalidation while the policy runs.
  • GrantsPermissionsTests: a grant gives its permissions after the check of the member, on the current instance only, in the current context only, and until the member returns, also across await. It takes precedence over the denials of the policy.
  • AccessGuardTests: audit records of denials and grants, labels in messages, and an auditor that runs without privileges.
  • SecurableListTests: the owner set on the first access and only once, changes denied without the write permission, with the kind of change, read-only properties secured too, the getter that fails closed on an ordinary list, and the owner hook.
  • RoleBasedPolicyTests: allow and deny rules, class hierarchy, built-in and application-defined computed roles, role inheritance through any parent, self-assignment, self-revocation, self-ownership except for the creator of an object, and the immutability of the policy.

Unit tests of the demo security model

Metalama.Samples.Authorization.Demo.UnitTests tests the security model of the invoicing application.

Its core is a permission matrix. Each row is an operation of the application with the set of users allowed to perform it:

Op( "INV-5: Approve a sales invoice", org => org.SalesInvoice.Approve(), Mia ),
Op( "CUS-3: Read a customer tax ID", org => _ = org.Fabrikam.TaxId, Mia, Ada, Otto, Grace ),

Each row starts with the identifier of the business requirement that it checks (see the security model of the demo), so the name of each test case gives the requirement, the operation, the user and the expected outcome.

The PermissionMatrixTests theory runs every operation as every user, on a fresh database. The objects are loaded from the database before the operation. The operation must succeed for the users of the row and throw AccessDeniedException for all the others. After a successful operation, the test saves the changes as the user, which must not trigger any check. Each row therefore checks both the grants and the denials, and adding a row is enough to cover a new operation. The rows describe the intended business rules, independently of how DemoPolicy implements them. The matrix found a gap during the development of this example: an auditor who was also a department manager could create invoices.

The other test classes cover what a single operation cannot show:

  • WorkflowTests: the invoice lifecycle, the creator who becomes the owner, the users who cannot make themselves the owner or revoke their own role, role changes that take effect once they are saved, transfers that change who inherits access, and the difference between a denied permission and a broken business rule.
  • OptionsBehaviorTests: proof at run time that each build-time option took effect.
  • StructuralRulesTests: the guarantees that do not depend on the policy, such as init-only names, private setters for references to secured objects, and read-only collections.
  • AuditTests: the content of audit records for the demo operations.
  • PolicyStorageTests: the rules are stored in the database, a changed rule takes effect after a reload, a rule on an unknown class, permission or role makes the reload fail without changing the policy, and a policy without rules denies everything.
  • SharedCacheTests: a request that works on data loaded before a change does not share its decisions, and a save in a transaction invalidates the shared cache after the commit, and not after a rollback.
  • PersistenceTests: loading and saving run no check; every field of every entity is mapped; authorized changes and role assignments are persisted and effective in a new database context; denied changes leave the database unchanged; queries are not filtered; and complete workflows run through the services as separate requests, including concurrent requests of different users.

Limitations

  • The cache of an execution context is cleared only after a change of the owner or of the role assignments. A long-running operation keeps the decisions of its start.
  • The cache shared by all execution contexts is invalidated after a save, in the process that saves. The other requests do not see a change that is not saved. Another instance of the application, or a change made directly in the database, is seen only when the decisions expire, after five minutes.
  • An object created by reflection, by a serializer, or by an assembly that is not compiled with Metalama is unusable until its creator calls ISecurable.CompleteInitialization. In the meantime, the creator can still set its properties in the same execution context.
  • Every access to a field or a property runs a check. A check that finds its decision in the cache of the execution context costs a dictionary lookup and the allocation of a Permission.
  • Rules apply to types, not to object states. "Only draft invoices can be edited" is a business rule in the Invoice methods.
  • A department manager can assign any role in their department to another person, including a role with more rights than their own. Nobody can assign a role to themself, but a real system also restricts which roles each role may assign.
  • The aspects protect members, not rows. A user without any role can query invoices, but cannot read their members. To filter rows, use another mechanism, for instance an Entity Framework global query filter.
  • The demo uses an in-memory database, created and seeded at startup, including the rules of the policy.