Skip to content

Commit 0b6881a

Browse files
authored
(#269) Blog posts converted into tutorial. (#284)
* (#274) Avalonia update to .NET 9 * (#269) Added blog posts as a tutorial
1 parent 80b6fd2 commit 0b6881a

File tree

578 files changed

+75961
-18
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

578 files changed

+75961
-18
lines changed

.github/workflows/build-docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
python-version: 3.x
3535

3636
- name: Install mkdocs
37-
run: pip install mkdocs
37+
run: pip install mkdocs mkdocs-mermaid2-plugin
3838

3939
- name: Setup Pages
4040
id: pages
105 KB
Loading
31.6 KB
Loading
33.4 KB
Loading
35.1 KB
Loading

docs/tutorial/server/part-1.md

Lines changed: 451 additions & 0 deletions
Large diffs are not rendered by default.

docs/tutorial/server/part-2.md

Lines changed: 323 additions & 0 deletions
Large diffs are not rendered by default.

docs/tutorial/server/part-3.md

Lines changed: 372 additions & 0 deletions
Large diffs are not rendered by default.

docs/tutorial/server/part-4.md

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# Implementing access control
2+
3+
This article is the fourth in a series of tutorials about the [Datasync Community Toolkit][toolkit], which is a set of open source libraries for building client-server applications where the application data is available offline. Thus far, I've [walked through creating a project](./part-1.md), [introduced you to the standard repositories](./part-2.md) and [custom repositories](./part-3.md).
4+
5+
Repositories are designed to be generic mechanisms to access the data you want to synchronize to your downstream clients. However, sometimes you need to alter what happens based on the user doing the synchronization. The Datasync Community Toolkit supports standard ASP.NET Core authentication and authorization, including Entra ID and ASP.NET Core Identity.
6+
7+
When you need to adjust the view or operations available on a per-user basis, you need to implement an Access Control Provider and attach it to your table controller.
8+
9+
## What is an Access Control Provider?
10+
11+
Let's start with the obvious question - what is an Access Control Provider? This is a class you write to control access to the repository through the table controller. It consists of three distinct parts:
12+
13+
1. A method that defines the view of the data that the user has.
14+
2. A method that decides whether the user can perform the in-flight operation.
15+
3. A method that is called before writing to the database to modify the in-flight entity.
16+
17+
To implement an access control provider, you implement `IAccessControlProvider<TEntity>`. This is located in the `CommunityToolkit.Datasync.Abstractions` NuGet package so you can put the access control providers in a separate project if needed.
18+
19+
!!! info
20+
There is a fourth element of an access control provider - the `PostCommitHook`. This is used for event management (such as notifying clients of changes in real-time) and not used in access control scenarios.
21+
22+
## An example access control provider
23+
24+
Let's say I have a model:
25+
26+
```csharp
27+
public class Article : EntityTableData
28+
{
29+
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
30+
31+
public string Content { get; set; }
32+
}
33+
```
34+
35+
I've already set up a repository and table controller:
36+
37+
```csharp
38+
public class ArticleController : TableController<Article>
39+
{
40+
public ArticleController(AppDbContext context, ILogger<ArticleController> logger) : base()
41+
{
42+
Repository = new EntityRepository<Article>(context);
43+
Logger = logger;
44+
}
45+
}
46+
```
47+
48+
Now, I want to implement some business rules:
49+
50+
* Anonymous users can only retrieve articles - no writing articles unless you are authenticated.
51+
* Users can only download articles created in the last 30 days.
52+
53+
This will demonstrate the first two methods that are needed for an access control provider.
54+
55+
!!! tip Using the IHttpContextAccessor
56+
Most access control providers need asynchronous access to the `HttpContext` of a request. This is handled using the `IHttpContextAccessor`. For details, see [the documentation](https://learn.microsoft.comaspnet/core/fundamentals/http-context). The documentation will tell you to add the following to your application services.
57+
58+
```csharp
59+
builder.Services.AddHttpContextAccessor();
60+
```
61+
62+
On to the access control provider. This is implemented in a class:
63+
64+
```csharp
65+
public class ArticleAccessControlProvider(IHttpContextAccessor contextAccessor) : IAccessControlProvider<Article>
66+
{
67+
private bool IsAuthenticated { get => contextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true; }
68+
69+
public Expression<Func<Article, bool>> GetDataView()
70+
=> article => article.CreatedAt > DateTimeOffset.AddDays(-30);
71+
72+
public ValueTask<bool> IsAuthorizedAsync(TableOperation op, Article? entity, CancellationToken cancellationToken = default)
73+
=> ValueTask.FromResult(op == TableOperation.Query || op == TableOperation.Read || IsAuthenticated);
74+
75+
public ValueTask PreCommitHookAsync(TableOperation op, Article entity, CancellationToken cancellationToken = default)
76+
=> ValueTask.CompletedTask;
77+
78+
public ValueTask PostCommitHookAsync(TableOperation op, TEntity entity, CancellationToken cancellationToken = default)
79+
=> ValueTask.CompletedTask;
80+
}
81+
```
82+
83+
Let's look at the pieces of `IAccessControlProvider`:
84+
85+
* `GetDataView()` returns the thing you would normally put in a `.Where()` clause of a LINQ expression. In fact, that's exactly what is done internally.
86+
* `IsAuthorizedAsync()` is called when the table controller needs to do something to the data.
87+
* `PreCommitHookAsync()` is called immediately prior to writing an entity to the database.
88+
* `PostCommitHookAsync()` is called immediately after writing an entity to the database and is not used in access control scenarios. It's useful to trigger other things though.
89+
90+
In this simple case, I've added `IsAuthenticated` as a property which returns true if the connection is authorized and false otherwise. This is used in the `IsAuthorizedAsync()` method to say "anyone can retrieve articles; only authenticated users can write articles."
91+
92+
The access control provider is attached to a table controller by setting the `AccessControlProvider` property:
93+
94+
```csharp
95+
[AllowAnonymous]
96+
public class ArticleController : TableController<Article>
97+
{
98+
public ArticleController(AppDbContext context, IAccessControlProvider<Article> accessControlProvider, ILogger<ArticleController> logger) : base()
99+
{
100+
Repository = new EntityRepository<Article>(context);
101+
Logger = logger;
102+
AccessControlProvider = accessControlProvider;
103+
}
104+
}
105+
```
106+
107+
Don't forget to register it with dependency injection:
108+
109+
```csharp
110+
builder.Services.AddHttpContextAccessor();
111+
builder.Services.AddScoped<IAccessControlProvider<Article>, ArticleAccessControlProvider>();
112+
```
113+
114+
Some additional notes:
115+
116+
* All the datasync work is done within an asynchoronous context. Ensure you follow thread-safe practices. In particular, be careful when accessing the `HttpContext`. The `IHttpContextAccessor` interface (and its associated services) is the right way to do this.
117+
* If you need to read the user context, you **MUST** decorate your controller with either `AllowAnonymous` or `Authorize`. The `HttpContext.User` is not populated unless you ask for it.
118+
119+
## A generic example: The personal table
120+
121+
Let's look at a common case - the personal table. What would I need to do so that a user can only create, update, delete, and view their own items? An access control provider is an excellent solution here. Let's start by modifying the entity:
122+
123+
```csharp
124+
public interface IPersonalEntity
125+
{
126+
string UserId { get; set; }
127+
}
128+
129+
public class TodoItem : EntityTableData, IPersonalEntity
130+
{
131+
[JsonIgnore]
132+
public string UserId { get; set; } = string.Empty;
133+
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
134+
public string Title { get; set; } = string.Empty;
135+
public bool IsComplete { get; set; } = false;
136+
}
137+
```
138+
139+
The `IPersonalEntity` interface allows me to ensure that the access control provider can be re-used for multiple entity types so long as the interface is followed. In the concrete implementation, I've ensured the `UserId` is not sent to the client application by getting the JSON serializer to ignore it.
140+
141+
Review the access control provider:
142+
143+
```csharp
144+
public class PersonalAccessControlProvider<TEntity>(IHttpContextAccessor contextAccessor) : IAccessControlProvider<TEntity>
145+
where TEntity : ITableData
146+
where TEntity : IPersonalEntity
147+
{
148+
private string? UserId { get => contextAccessor.HttpContext?.User?.Identity?.Name; }
149+
150+
public Expression<Func<TEntity, bool>> GetDataView()
151+
=> UserId is null ? x => false : x => x.UserId == UserId;
152+
153+
public ValueTask<bool> IsAuthorizedAsync(TableOperation op, TEntity? entity, CancellationToken cancellationToken = default)
154+
=> ValueTask.FromResult(op is TableOperation.Create || op is TableOperation.Query || entity.UserId == UserId);
155+
156+
public ValueTask PreCommitHookAsync(TableOperation op, TEntity entity, CancellationToken cancellationToken = default)
157+
{
158+
entity.UserId = UserId;
159+
return ValueTask.CompletedTask;
160+
}
161+
162+
public ValueTask PostCommitHookAsync(TableOperation op, TEntity entity, CancellationToken cancellationToken = default)
163+
=> ValueTask.CompletedTask;
164+
}
165+
```
166+
167+
* `UserId` is a property that pulls the current users ID from the identity `Name` property. You may want to use (for example) `User?.FindFirstValue(ClaimTypes.Email)` as an alternative to make the user ID an email address instead.
168+
* `GetDataView()` is careful to handle the case when the UserId is null to prevent leaking information. The UserId of the entity to be returned must match the UserId of the user.
169+
* `IsAuthorizedAsync()` allows the user to create new entities and read their own entities (since that's defined by `GetDataView()`). Anything else requires that the UserId matches.
170+
* Finally, `PreCommitHookAsync()` ensures that the entity UserId is set properly when storing the entity. Since the user is not specifying the UserId, it will get set on create and update / replace operations (plus deletions when soft-delete is enabled).
171+
172+
I can now apply this to my table controller:
173+
174+
```csharp
175+
[Authorize]
176+
[Route("tables/[controller]")]
177+
public class TodoItemsController : TableController<TodoItem>
178+
{
179+
public TodoItemsController(AppDbContext context, IHttpContextAccessor contextAccessor) : base()
180+
{
181+
Repository = new EntityTableRepository<TodoItem>(context);
182+
AccessControlProvider = new PersonalAccessControlProvider<TodoItem>(contextAccessor);
183+
}
184+
}
185+
```
186+
187+
I don't have to register all the access control providers with the services collection. I can just create a new one instead (as I have done here).
188+
189+
## Final thoughts
190+
191+
Access control providers are a good way to inject your specific business logic into the process. I've created access control providers in the past for these scenarios:
192+
193+
* The CRM model (driven by database models)
194+
* On the Customers model, only allow the user to download the customer accounts that they own and disallow creation and deletion of customer accounts.
195+
* On the CustomerNotes model, only allow the user to create a note for a customer account they own; retrieve notes for customer accounts they own; disallow update/delete of notes.
196+
* The Roles model (driven by ASP.NET Identity)
197+
* Administrators get to see everything; everyone else gets to see their own records.
198+
* Only designated individuals can delete records.
199+
* The followers model
200+
* A personal table allowing the user to add/remove accounts that they follow.
201+
* Then an articles table that allows the user to see the articles for the accounts that they follow (which is done via a Join and a custom repository).
202+
203+
This system allows you to customize what users can see and do at a very granular level.
204+
205+
## Further reading
206+
207+
* [The documentation](../../in-depth/server/index.md#configure-access-permissions)
208+
* [IHttpContextAccessor](https://learn.microsoft.com/aspnet/core/fundamentals/http-context)
209+
210+
<!-- Links -->
211+
[toolkit]: https://github.com/CommunityToolkit/Datasync

0 commit comments

Comments
 (0)