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 theOwnerof an object hasOwneron 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:
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
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.DepartmentrequiresChangeOwnership, the permission ofCustomer.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,
outparameters, and parameters whose type is not securable. A method or property check is made on the current instance, so its type must implementISecurable, 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, withbuilder.ExceptForInheritance().MustNotBeAbstract(). - When the attribute does not name a permission, the defaults of
AuthorizationOptionsapply. A method requiresWriteby 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:
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
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 );
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
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
asyncmember, 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:
- While its constructors run, the code that creates the object, in its execution context, accesses the object without checks.
- 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. - 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
BeforeInstanceConstructorinitializer callsSecurityContext.BeginInitialization( this )before the code of the constructors. - An
AfterLastInstanceConstructorinitializer callsSecurityContext.EndConstruction( this )at the end of the last constructor of the chain, from theOnConstructedmethod that Metalama introduces. - An
AfterObjectInitializerinitializer callsSecurityContext.EndInitialization( this )from theInitializemethod that Metalama introduces. Metalama rewrites eachnewexpression of a securable class to callInitializeafter the object initializer, sonew 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.Usedoes 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 asList<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.
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
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>andSecurableList<T>: the getter sets the owner of the securable list and returns it. A read-only type is secured too, so casting it back toIList<T>does not bypass the check. If the property returns an ordinary mutable collection, such as aList<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 warningAUTH003.
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 );
}
}
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
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.CustomerandCustomer.Departmenthave private setters, which only the class can call, andInvoice.ChangeCustomerandCustomer.MoveTocheck the permission on the new value. The private setters are checked too. - A guarantee that does not depend on the policy is structural.
Company.NameandDepartment.Nameare 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
IHasSecurityLabelbelow).
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} 21A namespace fabric makes finance data confidential. In the
Financenamespace, reading any member requiresReadSensitive. TheBudgetclass 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} 15The
[AuthorizationOptions]attribute, which implementsIHierarchicalOptionsProvider, sets options on a type or a member. OnCustomer, writing any property requiresApprove, because commercial terms are a management decision. The contact details override this back toWrite: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} 48Options are inherited by overriding members. The library excludes the abstract
RoleBasedSecurable.SecurityParentproperty 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, andAccessGuarduses 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.Denyrefuses 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
ChangeOwnershiporManageRoleschanges the data that the role-based policy reads. After it,AccessGuardclears 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 withImmutableInterlocked. The value of theAsyncLocal<T>field does not change. A decision cached by anasyncmethod 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.Grantstarts 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
ChangeOwnershiporManageRoles. 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.
Invalidateremoves the decisions on an object, and on the objects that depend on it, for instance the objects that inherit their roles from it.InvalidateAllremoves 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 callsRecordLoadwhen 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.
HasRolechecks the assignments of the object, then delegates toHasRoleof the security parent, so any implementation ofIRoleBasedSecurablecan be a parent. - A computed role is never assigned: the object computes it from its own data, and children do not inherit it.
Everyonematches every subject, andOwnermatches the owner of the object. An application defines more computed roles withRole.Computedand resolves them by overridingHasComputedRole. The demo definesCreator, 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
Denyrule. 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. SystemScopeMaterializationInterceptorcreates 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 ausingstatement, 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 asInvoice.Notes, is assigned by Entity Framework when it loads the object, so a value converter recreates aSecurableList<T>. EfRoleBasedSecurable, the base class ofEntity, stores the owner as aUsernavigation and the role assignments as an owned collection ofRoleAssignmentRecord.ConfigureRoleBasedStoragemaps them for each entity class. These storage members are excluded from default securing with[AuthorizationOptions( IsDefaultSecuringEnabled = false )]. The publicOwnerandRoleAssignmentsmembers 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
_amount1for_amount, which Entity Framework cannot discover by convention. A property with a private setter, such asBudget.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:
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
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,Usethat ends a system scope, and context isolation acrossawaitand 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 untilCompleteInitialization, and the parameterless constructor of a class that implementsISecurabledirectly.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 theasyncmethods 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 acrossawait. 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
Invoicemethods. - 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.