@drunkcoding/dknet-implementation-skills 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +38 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/agents/dknet-architect.md +49 -0
- package/agents/dknet-bdd-engineer.md +62 -0
- package/agents/dknet-implementer.md +75 -0
- package/package.json +52 -0
- package/plugin.json +26 -0
- package/skills/README.md +47 -0
- package/skills/dknet-auth-and-ownership/SKILL.md +418 -0
- package/skills/dknet-bdd-tests/SKILL.md +355 -0
- package/skills/dknet-bdd-tests/checklist.md +39 -0
- package/skills/dknet-crud/SKILL.md +483 -0
- package/skills/dknet-ddd-principles/SKILL.md +87 -0
- package/skills/dknet-docs/SKILL.md +296 -0
- package/skills/dknet-docs/checklist.md +58 -0
- package/skills/dknet-docs/templates/README-template.md +68 -0
- package/skills/dknet-docs/templates/api-reference-template.md +275 -0
- package/skills/dknet-docs/templates/architecture-template.md +166 -0
- package/skills/dknet-docs/templates/data-model-template.md +99 -0
- package/skills/dknet-docs/templates/events-template.md +155 -0
- package/skills/dknet-dto-mapping/SKILL.md +278 -0
- package/skills/dknet-efcore-config/SKILL.md +379 -0
- package/skills/dknet-endpoint/SKILL.md +458 -0
- package/skills/dknet-entity/SKILL.md +483 -0
- package/skills/dknet-feature/SKILL.md +139 -0
- package/skills/dknet-feature-lifecycle/SKILL.md +144 -0
- package/skills/dknet-feature-remove/SKILL.md +131 -0
- package/skills/dknet-messaging-events/SKILL.md +395 -0
- package/skills/dknet-package-adoption/SKILL.md +252 -0
- package/skills/dknet-platform-config/SKILL.md +342 -0
- package/skills/dknet-project-structure/SKILL.md +148 -0
- package/skills/dknet-queries-specs/SKILL.md +330 -0
- package/skills/dknet-scaffold/SKILL.md +209 -0
- package/skills/dknet-unit-tests/SKILL.md +382 -0
package/skills/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# dknet-minimal skills
|
|
2
|
+
|
|
3
|
+
Every folder here is one [Agent Skill](https://agentskills.io) (`<name>/SKILL.md`). The repository root is the
|
|
4
|
+
`dknet-minimal` Claude Code plugin; the same files are what `npx skills add baoduy/DKNet.Templates` installs and what
|
|
5
|
+
`@drunkcoding/dknet-minimal-skills` ships on npm. Start with `dknet-project-structure`.
|
|
6
|
+
|
|
7
|
+
## Workflows (invoke as a slash command with arguments)
|
|
8
|
+
|
|
9
|
+
| Skill | Arguments | Purpose |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `/dknet-bdd-tests` | `<Feature> e.g. Orders` | Create and maintain Reqnroll + NUnit BDD .feature scenarios and step bindings in Minimal.App.BDDTests — request/status/response-body scenarios and domain-event side effects observed via log capture, for the hand-written PurchaseOrder and generator-driven Product samples. Use when adding or updating HTTP-facing scenarios for a DKNet.Templates feature. Result-level Result-object assertions, architecture rules and pure functional tests belong in the `dknet-unit-tests` skill instead — do not duplicate a behavior here that xUnit already covers. Invoke as `/dknet-bdd-tests <Feature>` to scaffold it for a feature. |
|
|
12
|
+
| `/dknet-crud` | `<Feature> <Entity> [mode=manual\|auto] [version=V1]` | Create commands (Create/Update/business transitions/Delete) at the AppServices layer for a DKNet.Minimal feature — request contracts, FluentValidation validators, and SlimMessageBus handlers, in both the hand-written and generator-driven (CrudCreate/CrudUpdate/CrudAction) shapes. Use after the domain entity and EF Core mapper exist. Queries and paged lists are the `dknet-queries-specs` skill; response DTOs and Mapster are `dknet-dto-mapping`. Invoke as `/dknet-crud <Feature> <Entity> [mode=manual\|auto] [version=V1]` to scaffold it for a feature. |
|
|
13
|
+
| `/dknet-docs` | `<Feature>` | Generate structured technical documentation and Mermaid architecture diagrams for completed features. Use this when documenting implemented features with README, architecture diagrams, and API references. Invoke as `/dknet-docs <Feature>` to scaffold it for a feature. |
|
|
14
|
+
| `/dknet-endpoint` | `<Feature> <Entity> [mode=manual\|auto] [routePrefix] [version=V1]` | Create Minimal API endpoint configurations using this project's IEndpointConfig pattern — raw minimal-API routes mapped by hand, a single generated Map<Entity>Crud() call, or the underlying DKNet.AspCore.Extensions generic route helpers called directly. Use after AppServices actions (or CrudCreate/CrudUpdate/CrudAction entity attributes) are ready, to expose a feature over HTTP. Invoke as `/dknet-endpoint <Feature> <Entity> [mode=manual\|auto] [routePrefix] [version=V1]` to scaffold it for a feature. |
|
|
15
|
+
| `/dknet-entity` | `<Feature> <Entity> [mode=manual\|auto] [props…] e.g. Orders Order mode=manual Number:string Total:decimal` | Create DDD domain entities following this project's AggregateRoot/DomainEntity inheritance pattern, either hand-written (mode=manual) or generator-declared (mode=auto). Use when adding a new domain entity or owned type to Minimal.Domains. Invoke as `/dknet-entity <Feature> <Entity> [mode=manual\|auto] [props…]` to scaffold it for a feature. |
|
|
16
|
+
| `/dknet-feature-remove` | `<Feature> [--dry-run] e.g. Orders` | Retire a DKNet vertical-slice business feature end-to-end — deletes its folders across all six projects, cleans the out-of-folder touchpoints, and drops its tables via a new migration. |
|
|
17
|
+
| `/dknet-feature` | `<Feature> <Entity> [mode=manual\|auto] [props…] e.g. Orders Order mode=manual Number:string Total:decimal` | Drive an end-to-end DKNet vertical-slice feature from plan to merged tests in either the manual or automated flow — orchestrates entity, CRUD, endpoint, tests, BDD, and docs. |
|
|
18
|
+
| `/dknet-unit-tests` | `<Feature> <Entity> [mode=manual\|auto]` | Write xUnit + Shouldly tests for a DKNet.Templates feature in Minimal.App.Tests — architecture/convention rules, pure functional tests (entity methods, validators, specs, mapping), and result-level integration tests against ApiFixture + IMessageBus (handler Result failures, EF persistence, domain events). Use after AppServices actions and endpoint config are ready. Covers only what xUnit owns; HTTP request/response and event-log scenarios belong in the `dknet-bdd-tests` skill. Invoke as `/dknet-unit-tests <Feature> <Entity> [mode=manual\|auto]` to scaffold it for a feature. |
|
|
19
|
+
|
|
20
|
+
## Reference skills (loaded by the agent when the topic comes up)
|
|
21
|
+
|
|
22
|
+
| Skill | Teaches |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `dknet-auth-and-ownership` | Explains authentication, per-route authorization scopes, acting-user attribution, row-level data ownership, and role-aware sensitive-data filtering in this template — including the demo authentication provider and how to write a test that runs with RequireAuthorization on. Use whenever adding a scope-guarded route, wiring acting-user attribution for a new feature, reasoning about data isolation between callers, or writing an auth-on integration test. |
|
|
25
|
+
| `dknet-ddd-principles` | DDD tactical judgment for this codebase — aggregate boundaries, entity vs. value object, invariant enforcement, when to use a domain event, avoiding anemic domain models. Use before dknet-entity and dknet-crud whenever the aggregate shape or business-rule placement isn't obvious. |
|
|
26
|
+
| `dknet-dto-mapping` | Design response DTOs and Mapster mapping for a DKNet.Minimal feature — hand-written vs [GenerateDto] shapes, custom Mapster IRegister mappings for values the generator's convention can't produce, LazyMapper, and the JSON/sensitive-data contract. Use after (or alongside) dknet-crud when a handler needs a DTO to return. |
|
|
27
|
+
| `dknet-efcore-config` | Create EF Core entity type configurations (mappers), static data seeders, CoreDbContext wiring, and infra domain-service implementations following this project's assembly-scan auto-discovery conventions. Use after creating a domain entity, for both mode=manual and mode=auto entities. |
|
|
28
|
+
| `dknet-feature-lifecycle` | The add/remove lifecycle of a DKNet vertical-slice business feature — how to choose the manual vs automated flow, the exact file footprint a feature occupies across all six projects, and the out-of-folder touchpoints a delete must clean up. Use before /dknet-feature or /dknet-feature-remove, and whenever you need to enumerate or retire an existing feature. |
|
|
29
|
+
| `dknet-messaging-events` | Explains how this template wires SlimMessageBus as its command/query/event backbone, how the two domain-event styles (manual AddEvent vs declared RaisesEvent) reach a subscriber, and how to forward an event to an external Azure Service Bus topic. Use whenever adding a domain event, wiring an internal or external event consumer, or reasoning about how a command/query travels from an endpoint to its handler. |
|
|
30
|
+
| `dknet-package-adoption` | Add DKNet's Core, EF Core, messaging/CQRS, or blob-storage NuGet packages to an EXISTING .NET project that was NOT created from the DKNet.Minimal.Template. Use when a consumer wants to reuse pieces of the DKNet framework in their own project layout without scaffolding a new solution. |
|
|
31
|
+
| `dknet-platform-config` | Reference for everything the template wires outside a feature's own vertical slice — Program.cs start-up order, the FeatureManagement flag table, every configuration section and its defaults, launch-time jobs, the Aspire host, and the test-host overrides. Use when adding or changing a platform-level behavior (auth, rate limiting, health checks, telemetry, caching, CORS, a new flag, a new launch job) rather than a business feature. |
|
|
32
|
+
| `dknet-project-structure` | Orientation to the DKNet.Minimal.Template layer boundaries, the six projects, the vertical-slice folder layout for a feature, and auto-discovery wiring. Use first, before any other dknet-* skill, when working in a solution generated from this template. |
|
|
33
|
+
| `dknet-queries-specs` | Write read/query logic for a DKNet feature — Specification<T> filters, hand-written query requests/handlers dispatched over IMessageBus, and the generic filter/search/order/page list route every generator-driven CRUD slice gets for free. Use after the domain entity and DTO exist, whenever a feature needs anything more than the default GET-by-id. |
|
|
34
|
+
| `dknet-scaffold` | Scaffold a new solution from the DKNet.Minimal template and get it running — dotnet new install/new, the six template parameters, what the generated tree looks like, first build/run with or without Aspire, deleting the two shipped sample features, and installing this skill plugin into the generated repo. Use when starting a new DKNet.Minimal solution, or when orienting inside a freshly generated one. |
|
|
35
|
+
|
|
36
|
+
## Claude Code subagents (`../agents/`)
|
|
37
|
+
|
|
38
|
+
- `dknet-architect` — Use when planning a new feature in a DKNet.Minimal.Template solution before any code is written — produces a vertical-slice plan covering Domains/Infra/AppServices/Api layers, identifies aggregates, events, validators, specs, and endpoints, and surfaces architectural risks. Read-only research; does not modify code.
|
|
39
|
+
- `dknet-bdd-engineer` — Use to add or update Reqnroll + NUnit BDD scenarios for a DKNet feature. Builds .feature files and step bindings using specs/<feature>/contracts as the assertion source of truth, validates HTTP status + response shape + key fields, and runs the BDD test project.
|
|
40
|
+
- `dknet-implementer` — Use to implement an approved DKNet feature plan end-to-end across Domains, Infra, AppServices, and Api layers, including EF migration, FluentValidation, Mapster DTOs, domain events, and endpoint wiring. Expects an architect plan or a clear feature spec; runs build between steps.
|
|
41
|
+
|
|
42
|
+
## Authoring
|
|
43
|
+
|
|
44
|
+
Rules every skill follows (enforced by `../validate-plugin.sh`): frontmatter `name` equals the folder, a single-line
|
|
45
|
+
`description` (max 1024 chars), paths relative to the consumer solution root (`ApiEndpoints/Minimal.*`, never `src/`),
|
|
46
|
+
other skills referenced by name, no links into this repository's `docs/`, exemplar code inlined. This index is
|
|
47
|
+
generated from the frontmatter — regenerate it after changing a skill.
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dknet-auth-and-ownership
|
|
3
|
+
description: Explains authentication, per-route authorization scopes, acting-user attribution, row-level data ownership, and role-aware sensitive-data filtering in this template — including the demo authentication provider and how to write a test that runs with RequireAuthorization on. Use whenever adding a scope-guarded route, wiring acting-user attribution for a new feature, reasoning about data isolation between callers, or writing an auth-on integration test.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# DKNet authentication, authorization, and ownership
|
|
7
|
+
|
|
8
|
+
## `FeatureManagement:RequireAuthorization`
|
|
9
|
+
|
|
10
|
+
When `true`, `AddAppConfig` calls `AddAuthConfig` (`Minimal.Api/Configs/Auth/AuthConfig.cs`):
|
|
11
|
+
|
|
12
|
+
```csharp
|
|
13
|
+
services.AddAuthentication().AddJwtBearer();
|
|
14
|
+
|
|
15
|
+
services.AddAuthorization(options =>
|
|
16
|
+
{
|
|
17
|
+
// Default deny: any endpoint not explicitly declared anonymous requires an authenticated caller.
|
|
18
|
+
options.FallbackPolicy = new AuthorizationPolicyBuilder()
|
|
19
|
+
.RequireAuthenticatedUser()
|
|
20
|
+
.Build();
|
|
21
|
+
|
|
22
|
+
options.AddPolicy(HasScopeRequirement.PolicyName,
|
|
23
|
+
policy => policy.Requirements.Add(new HasScopeRequirement("sample-scope")));
|
|
24
|
+
|
|
25
|
+
foreach (var scope in ProductScopes.All)
|
|
26
|
+
options.AddPolicy(scope, policy => policy.Requirements.Add(new HasScopeRequirement(scope)));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
services.AddScoped<IClaimsTransformation, SampleClaimsTransformation>();
|
|
30
|
+
services.AddScoped<IAuthorizationHandler, HasScopeHandler>();
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
JWT bearer validation reads its metadata address from `Authentication:Schemes:Bearer:MetadataAddress`
|
|
34
|
+
(plus `ValidAudiences`/`ValidIssuer`). `FallbackPolicy` is default-deny: any route without an
|
|
35
|
+
explicit `[AllowAnonymous]` requires an authenticated caller, even one with no scope requirement
|
|
36
|
+
attached. `HasScopeRequirement`/`HasScopeHandler` check the `scp` or `scope` claim (space-separated,
|
|
37
|
+
both spellings checked for provider portability). `SampleClaimsTransformation` is a `TODO`-marked
|
|
38
|
+
seam for enriching the principal after authentication — replace it, don't add a parallel one.
|
|
39
|
+
|
|
40
|
+
When `RequireAuthorization` is `false`, **no authentication or authorization middleware is
|
|
41
|
+
registered at all**, unless `EnableDemoAuthentication` is separately on (below). There is no
|
|
42
|
+
implicit fallback identity.
|
|
43
|
+
|
|
44
|
+
`appsettings.json` ships `RequireAuthorization: true`. Both `appsettings.Development.json` and
|
|
45
|
+
`appsettings.Testing.json` override it to `false` and turn `EnableDemoAuthentication` on instead —
|
|
46
|
+
local development and the test hosts do not exercise real JWT validation by default.
|
|
47
|
+
|
|
48
|
+
## Scopes → policies
|
|
49
|
+
|
|
50
|
+
`Minimal.Api/ApiEndpoints/AutomatedSample/ProductScopes.cs` defines the scope constants for the
|
|
51
|
+
`Product` feature:
|
|
52
|
+
|
|
53
|
+
```csharp
|
|
54
|
+
internal static class ProductScopes
|
|
55
|
+
{
|
|
56
|
+
public const string Read = "products.read";
|
|
57
|
+
public const string Write = "products.write";
|
|
58
|
+
public const string Supplier = "products.supplier";
|
|
59
|
+
public const string Discontinue = "products.discontinue";
|
|
60
|
+
public static readonly string[] All = [Read, Write, Supplier, Discontinue];
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`AuthConfig`'s `foreach (var scope in ProductScopes.All)` loop registers **one authorization policy
|
|
65
|
+
per scope, the scope value doubling as its own policy name** — so a route calls
|
|
66
|
+
`.RequireAuthorization(ProductScopes.Read)` directly, no separate policy name to remember.
|
|
67
|
+
|
|
68
|
+
A route only calls `RequireAuthorization(scope)` when the flag is actually on — calling it
|
|
69
|
+
unconditionally would throw at request time if `RequireAuthorization` is off, because no policies
|
|
70
|
+
were registered for the app to resolve against. `ProductV1Endpoint.Map` reads the flag once via DI
|
|
71
|
+
and branches on it:
|
|
72
|
+
|
|
73
|
+
```csharp
|
|
74
|
+
var requireAuthorization = ((IEndpointRouteBuilder)group).ServiceProvider
|
|
75
|
+
.GetRequiredService<IOptions<FeatureOptions>>().Value.RequireAuthorization;
|
|
76
|
+
|
|
77
|
+
group.MapProductCrud(o =>
|
|
78
|
+
{
|
|
79
|
+
o.Exclude("Discontinue");
|
|
80
|
+
if (!requireAuthorization) return;
|
|
81
|
+
|
|
82
|
+
o.Configure(CrudOp.GetById, rb => rb.RequireAuthorization(ProductScopes.Read));
|
|
83
|
+
o.Configure(CrudOp.GetList, rb => rb.RequireAuthorization(ProductScopes.Read));
|
|
84
|
+
o.Configure(CrudOp.Create, rb => rb.RequireAuthorization(ProductScopes.Write));
|
|
85
|
+
o.Configure(CrudOp.Update, rb => rb.RequireAuthorization(ProductScopes.Write));
|
|
86
|
+
o.Configure(CrudOp.Delete, rb => rb.RequireAuthorization(ProductScopes.Write));
|
|
87
|
+
o.Configure("Approve", rb => rb.RequireAuthorization(ProductScopes.Write));
|
|
88
|
+
// Its own scope, not Write — holding only products.write must not be enough to assign it.
|
|
89
|
+
o.Configure("AssignSupplierReference", rb => rb.RequireAuthorization(ProductScopes.Supplier));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
var discontinue = group.MapPut("{id:guid}/discontinue", /* ... */);
|
|
93
|
+
if (requireAuthorization) discontinue.RequireAuthorization(ProductScopes.Discontinue);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`o.Configure(CrudOp, ...)` targets a generated composite route by operation kind (see
|
|
97
|
+
`dknet-endpoint`); `o.Configure("RouteName", ...)` targets a specific `[CrudAction]` route by
|
|
98
|
+
its C# member name. A hand-mapped route (`discontinue` above) calls `.RequireAuthorization(scope)`
|
|
99
|
+
directly on the `RouteHandlerBuilder` the same way.
|
|
100
|
+
|
|
101
|
+
`IEndpointConfig` also exposes an optional `string? AuthPolicy` member for gating an entire group
|
|
102
|
+
under one policy (`null` means plain authentication) rather than per-route scopes — neither shipped
|
|
103
|
+
sample overrides it; both use per-route `RequireAuthorization(scope)` inside `Map` instead, because
|
|
104
|
+
different routes in the same group need different scopes.
|
|
105
|
+
|
|
106
|
+
**Recipe: add a new scope for a new feature.**
|
|
107
|
+
1. Add a constant (and to an `All` array, if you loop like `ProductScopes` does) in a
|
|
108
|
+
`<Feature>Scopes` static class next to the feature's endpoint config.
|
|
109
|
+
2. Register it as a policy — either loop over your `All` array in `AuthConfig` the way
|
|
110
|
+
`ProductScopes.All` is registered, or call `options.AddPolicy(YourScopes.X, ...)` explicitly for a
|
|
111
|
+
one-off scope.
|
|
112
|
+
3. Call `.RequireAuthorization(YourScopes.X)` on the route, gated behind the same
|
|
113
|
+
`FeatureOptions.RequireAuthorization` check `ProductV1Endpoint` uses — never call it
|
|
114
|
+
unconditionally.
|
|
115
|
+
|
|
116
|
+
## Demo authentication
|
|
117
|
+
|
|
118
|
+
`FeatureManagement:EnableDemoAuthentication` registers `DemoAuthenticationHandler`
|
|
119
|
+
(`Minimal.Api/Configs/Auth/DemoAuthConfig.cs`) as the default authenticate/challenge scheme. Every
|
|
120
|
+
request is authenticated, unconditionally, as a fixed fake identity:
|
|
121
|
+
|
|
122
|
+
```csharp
|
|
123
|
+
var identity = new ClaimsIdentity(
|
|
124
|
+
[
|
|
125
|
+
new Claim(ClaimTypes.Name, SharedConsts.DemoAccount),
|
|
126
|
+
new Claim(ClaimTypes.NameIdentifier, SharedConsts.SystemAccount)
|
|
127
|
+
],
|
|
128
|
+
SchemeName);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`AddAppConfig` throws at start-up if both `RequireAuthorization` and `EnableDemoAuthentication` are
|
|
132
|
+
`true` — the demonstration identity is never a real caller. Demo authentication exists to give
|
|
133
|
+
local/demo runs a real authenticated principal, so acting-user attribution and ownership stamping
|
|
134
|
+
still work, without standing up a real identity provider. It never gates access — no authorization
|
|
135
|
+
services or policies are registered alongside it.
|
|
136
|
+
|
|
137
|
+
## Acting user — three surfaces
|
|
138
|
+
|
|
139
|
+
**Manual: `[FromClaim]`.** `CreatePurchaseOrderRequest.ByUser` is populated by
|
|
140
|
+
`AddContextualRequestPopulation` (wired in `Program.cs`) before FluentValidation runs and before the
|
|
141
|
+
handler is invoked:
|
|
142
|
+
|
|
143
|
+
```csharp
|
|
144
|
+
[FromClaim(ClaimTypes.Name)]
|
|
145
|
+
public string? ByUser { get; set; }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
There is no fallback: if the caller has no `ClaimTypes.Name` claim, `ByUser` stays `null`/empty, and
|
|
149
|
+
the handler must reject it explicitly —
|
|
150
|
+
|
|
151
|
+
```csharp
|
|
152
|
+
if (string.IsNullOrEmpty(request.ByUser))
|
|
153
|
+
return Result.Fail<PurchaseOrderDto>("The caller is not authenticated.");
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
— from `CreatePurchaseOrderCommandHandler`. A payload value for `ByUser` is always overwritten, never
|
|
157
|
+
trusted; this is a security seam, not a binding convenience.
|
|
158
|
+
|
|
159
|
+
**Automated: `PrincipalProvider` + two save hooks.** A `[CrudCreate]`/`[CrudUpdate]`/`[CrudAction]`
|
|
160
|
+
generated request forwards only `System.ComponentModel.DataAnnotations` attributes, so it can never
|
|
161
|
+
carry `[FromClaim]`. Acting-user attribution instead goes through `PrincipalProvider`
|
|
162
|
+
(`Minimal.Api/Configs/Handlers/PrincipalProvider.cs`), which implements `IPrincipalProvider`
|
|
163
|
+
(`IDataOwnerProvider` + `ICurrentUserProvider` plus `ProfileId`/`Email`/`UserName`):
|
|
164
|
+
|
|
165
|
+
```csharp
|
|
166
|
+
string[] subjectClaimTypes =
|
|
167
|
+
[
|
|
168
|
+
"http://schemas.microsoft.com/identity/claims/objectidentifier", "oid", ClaimTypes.NameIdentifier, "sub"
|
|
169
|
+
];
|
|
170
|
+
// first non-empty of these wins
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Unauthenticated callers resolve to `SharedConsts.SystemAccount`. `ServiceConfigs.AddAllAppServices`
|
|
174
|
+
wires it twice, deliberately as two separate hooks over the same value today:
|
|
175
|
+
|
|
176
|
+
```csharp
|
|
177
|
+
.AddDataOwnerProvider<CoreDbContext, PrincipalProvider>() // stamps OwnedBy on insert, row-isolation key
|
|
178
|
+
.AddCurrentUserProvider<CoreDbContext, PrincipalProvider>() // stamps CreatedBy/UpdatedBy from the same subject
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`GetOwnershipKey()` and `GetCurrentUser()` return the same value today — kept as two methods (not
|
|
182
|
+
collapsed into one) because the two hooks are wired independently and are free to diverge later if
|
|
183
|
+
ownership and audit attribution ever need different claims.
|
|
184
|
+
|
|
185
|
+
A domain method can still override the audit stamp explicitly — `Product.Approve(string byUser) =>
|
|
186
|
+
SetUpdatedBy(byUser)` calls the base `SetUpdatedBy` directly, so `UpdatedBy` reflects the payload's
|
|
187
|
+
`byUser` argument rather than the caller's own principal for that one action.
|
|
188
|
+
|
|
189
|
+
**Table: manual vs. automated acting-user attribution**
|
|
190
|
+
|
|
191
|
+
| | Manual (`PurchaseOrder`) | Automated (`Product`) |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| Carried on | `[FromClaim]` request property | Not on the request at all |
|
|
194
|
+
| Populated by | `AddContextualRequestPopulation`, pre-validation | `PrincipalProvider`, at `SaveChanges` |
|
|
195
|
+
| Empty/missing case | Handler must check and reject | `CreatedBy`/`UpdatedBy` stamped `SystemAccount`, or the write is refused (see ownership below) |
|
|
196
|
+
| Override per-action | Pass a different value in the payload (validator's job to police that) | A domain method can call `SetUpdatedBy(explicitUser)` directly, as `Approve` does |
|
|
197
|
+
|
|
198
|
+
**`[CrudAction]` pitfall.** A generated action method's parameter *name* becomes a bindable request
|
|
199
|
+
property, regardless of what it's meant to represent. `Product.Approve(string byUser) =>
|
|
200
|
+
SetUpdatedBy(byUser)` generates `ApproveProductRequest` with a caller-settable, `required string
|
|
201
|
+
ByUser` property — any caller can claim to be approving as anyone. This is by design for `Approve`
|
|
202
|
+
(the BDD suite calls it "approve as X"), but it means **never name a `[CrudAction]` parameter
|
|
203
|
+
`byUser`/similar expecting it to be silently populated from the caller's identity** — a generated
|
|
204
|
+
request has no `[FromClaim]` seam at all.
|
|
205
|
+
|
|
206
|
+
## Row-level isolation
|
|
207
|
+
|
|
208
|
+
`Product : AggregateRoot, IOwnedBy` carries an `OwnedBy` property. `CoreDbContext : IDataOwnerDbContext`
|
|
209
|
+
exposes:
|
|
210
|
+
|
|
211
|
+
```csharp
|
|
212
|
+
public IEnumerable<string> AccessibleKeys =>
|
|
213
|
+
_dataKeyProvider is not null ? _dataKeyProvider.GetAccessibleKeys() : [];
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
DKNet's global query filter reads `AccessibleKeys` to scope every `IOwnedBy` entity's reads to the
|
|
217
|
+
current caller — `ProductOwnershipIsolationTests` proves two authenticated callers sharing one
|
|
218
|
+
host/database get distinct ownership keys and neither can read or list the other's row (a cross-read
|
|
219
|
+
returns 404, not 403 — the filter denies it as "not found", it does not reveal existence).
|
|
220
|
+
|
|
221
|
+
Before every `SaveChanges`, `CoreDbContext.EnsureOwnershipResolvable()` fails closed:
|
|
222
|
+
|
|
223
|
+
```csharp
|
|
224
|
+
private void EnsureOwnershipResolvable()
|
|
225
|
+
{
|
|
226
|
+
if (_dataKeyProvider is null) return;
|
|
227
|
+
if (!string.IsNullOrEmpty(_dataKeyProvider.GetOwnershipKey())) return;
|
|
228
|
+
|
|
229
|
+
var hasUnattributableInsert = ChangeTracker.Entries()
|
|
230
|
+
.Any(e => e.State == EntityState.Added
|
|
231
|
+
&& e.Entity is IAuditedProperties { CreatedBy: null or "" }
|
|
232
|
+
&& e.Metadata.FindProperty(nameof(IAuditedProperties.CreatedBy)) is { IsNullable: false });
|
|
233
|
+
|
|
234
|
+
if (hasUnattributableInsert) throw new OwnershipRequiredException();
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
If an authenticated caller's ownership key cannot be resolved and a new row would be left with no
|
|
239
|
+
`CreatedBy`, this throws `OwnershipRequiredException` **before** EF Core attempts the insert —
|
|
240
|
+
otherwise EF Core's own required-column check would throw a raw `DbUpdateException` that leaks
|
|
241
|
+
column/entity names into the response. `FluentValidationConfig.AddErrorResponses` maps that
|
|
242
|
+
exception to **403 Forbidden**:
|
|
243
|
+
|
|
244
|
+
```csharp
|
|
245
|
+
o.StatusCode = ctx =>
|
|
246
|
+
{
|
|
247
|
+
if (ctx.Source == ErrorSource.Unhandled && ctx.Exception is OwnershipRequiredException)
|
|
248
|
+
return StatusCodes.Status403Forbidden;
|
|
249
|
+
return ctx.Errors.Any(e => e.Code?.StartsWith(PreconditionCodes.Prefix, StringComparison.Ordinal) == true)
|
|
250
|
+
? StatusCodes.Status409Conflict
|
|
251
|
+
: null;
|
|
252
|
+
};
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`ProductOwnershipIsolationTests.AuthenticatedCallerWithNoSubjectClaim_IsRefusedWithForbidden_NoRowPersistedOrReadable`
|
|
256
|
+
pins the whole chain: an authenticated caller with no resolvable subject claim gets 403, and the row
|
|
257
|
+
is never persisted at all (checked with `IgnoreQueryFilters()` directly against the database, not
|
|
258
|
+
just "unreadable over HTTP").
|
|
259
|
+
|
|
260
|
+
**Tests that pin this area** (all in `Minimal.App.Tests/Integration/...`):
|
|
261
|
+
|
|
262
|
+
- `AutomatedSample/V1/ProductOwnershipIsolationTests` — two distinct authenticated callers get
|
|
263
|
+
distinct ownership keys; neither can read or list the other's row; `oid` takes precedence over
|
|
264
|
+
`NameIdentifier` when both are present; a caller with no resolvable subject claim is refused 403
|
|
265
|
+
with no row left behind.
|
|
266
|
+
- `AutomatedSample/V1/ProductAuditStampTests` — the acting caller is recorded as `CreatedBy`/
|
|
267
|
+
`UpdatedBy` even under a **fixed tenant** ownership key, proving `OwnedBy` (tenant) and
|
|
268
|
+
`CreatedBy`/`UpdatedBy` (individual actor) are stamped independently and never conflated.
|
|
269
|
+
- `AutomatedSample/V1/ProductSecurityTests` — `CreatedBy`/`UpdatedBy` come from the authenticated
|
|
270
|
+
caller's own ownership key, never from any acting-user-shaped field in the request payload; an
|
|
271
|
+
explicit action (`Approve`) is the one place a payload-supplied acting user is legitimately
|
|
272
|
+
honored.
|
|
273
|
+
- `ManualSample/V1/PurchaseOrderSecurityTests` — `[FromClaim] ByUser` always wins over any `byUser`
|
|
274
|
+
value present in the request body or query string.
|
|
275
|
+
- `ManualSample/V1/PurchaseOrderNoNameClaimAttributionTests` — with no `ClaimTypes.Name` claim at
|
|
276
|
+
all, update/cancel/delete are all refused 400 and the stored order is unchanged.
|
|
277
|
+
|
|
278
|
+
## Sensitive data
|
|
279
|
+
|
|
280
|
+
`[SensitiveData("role")]` (DKNet.EfCore.Abstractions.Attributes) on an entity property means the
|
|
281
|
+
JSON response omits that property for any caller who does not hold the named role; `[SensitiveData]`
|
|
282
|
+
with no role means it is omitted for any caller who is not authenticated at all — any authenticated
|
|
283
|
+
caller receives it regardless of role. On `Product`:
|
|
284
|
+
|
|
285
|
+
```csharp
|
|
286
|
+
[SensitiveData("pricing")]
|
|
287
|
+
public decimal? SupplierCostPrice { get; private set; }
|
|
288
|
+
|
|
289
|
+
[SensitiveData]
|
|
290
|
+
public string? SupplierReferenceCode { get; private set; }
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
The attribute travels onto the generated DTO automatically; a hand-written DTO member that mirrors a
|
|
294
|
+
`[SensitiveData]` entity property must carry the same attribute itself — it is not inherited through
|
|
295
|
+
a plain property copy.
|
|
296
|
+
|
|
297
|
+
Filtering is opt-in at the JSON-serialization layer, wired once in `ServiceConfigs.AddOptions`:
|
|
298
|
+
|
|
299
|
+
```csharp
|
|
300
|
+
services.AddSingleton<IConfigureOptions<JsonOptions>>(sp =>
|
|
301
|
+
new ConfigureOptions<JsonOptions>(op =>
|
|
302
|
+
op.SerializerOptions.UseRoleAwareSensitiveData(
|
|
303
|
+
sp.GetRequiredService<ISensitiveDataPrincipalAccessor>())));
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`ISensitiveDataPrincipalAccessor`'s implementation is a two-line adapter over the already-registered
|
|
307
|
+
`IHttpContextAccessor`:
|
|
308
|
+
|
|
309
|
+
```csharp
|
|
310
|
+
internal sealed class HttpContextSensitiveDataPrincipalAccessor(IHttpContextAccessor httpContextAccessor)
|
|
311
|
+
: ISensitiveDataPrincipalAccessor
|
|
312
|
+
{
|
|
313
|
+
public ClaimsPrincipal? Current => httpContextAccessor.HttpContext?.User;
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
**Tests:** `ProductSensitiveDataTests` (role-bearing vs. non-role-bearing authenticated callers, and
|
|
318
|
+
that two callers are judged independently in either request order), `ProductSensitiveDataAnonymousTests`
|
|
319
|
+
(an anonymous caller gets neither sensitive property but still gets the ordinary ones),
|
|
320
|
+
`ProductSensitiveDataDemoAuthenticatedTests` (the demo identity gets the no-role property but not the
|
|
321
|
+
`"pricing"`-role one, since the demo identity holds no roles).
|
|
322
|
+
|
|
323
|
+
## Idempotency, in one paragraph
|
|
324
|
+
|
|
325
|
+
POST routes are not idempotent unless the route calls `.RequiredIdempotentKey()` explicitly, with
|
|
326
|
+
callers sending `X-Idempotency-Key`. The store is Redis when `ConnectionStrings:Redis` is set, else
|
|
327
|
+
an in-process in-memory store; both use `ConflictHandling = IdempotentConflictHandling.ConflictResponse`
|
|
328
|
+
(`Minimal.Api/Configs/AppConfig.cs`). See `dknet-crud` for how a handler's `Result`
|
|
329
|
+
maps to a response body, and `dknet-platform-config` for CORS, security headers, and rate limiting.
|
|
330
|
+
Status mapping: 400 validation, 401 no/invalid credential, 403 `OwnershipRequiredException` or a
|
|
331
|
+
failed authorization policy, 404 not found or filtered out by ownership, 409 a
|
|
332
|
+
`PreconditionCodes`-prefixed error, 429 rate limit, 500 unhandled.
|
|
333
|
+
|
|
334
|
+
## Testing with authorization on
|
|
335
|
+
|
|
336
|
+
`Program.cs` binds `FeatureOptions` from configuration in its very first lines — before
|
|
337
|
+
`WebApplicationFactory`'s own `ConfigureAppConfiguration` override is merged in. A plain
|
|
338
|
+
configuration override registered through the test factory's usual hook therefore cannot flip
|
|
339
|
+
`RequireAuthorization` or `EnableDemoAuthentication`, because that early bind has already run by the
|
|
340
|
+
time the override would apply. The shipped fixtures work around this by setting environment
|
|
341
|
+
variables in the fixture's constructor, which **are** visible to `WebApplication.CreateBuilder(args)`
|
|
342
|
+
at the point it builds its initial configuration:
|
|
343
|
+
|
|
344
|
+
```csharp
|
|
345
|
+
public sealed class AuthOnApiFixture : TestApiFactoryBase, IAsyncLifetime
|
|
346
|
+
{
|
|
347
|
+
private const string RequireAuthorizationEnvKey = "FeatureManagement__RequireAuthorization";
|
|
348
|
+
private const string EnableDemoAuthenticationEnvKey = "FeatureManagement__EnableDemoAuthentication";
|
|
349
|
+
|
|
350
|
+
public AuthOnApiFixture()
|
|
351
|
+
{
|
|
352
|
+
Environment.SetEnvironmentVariable(RequireAuthorizationEnvKey, "true");
|
|
353
|
+
Environment.SetEnvironmentVariable(EnableDemoAuthenticationEnvKey, "false");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
protected override void ConfigureTestServices(IServiceCollection services)
|
|
357
|
+
{
|
|
358
|
+
base.ConfigureTestServices(services);
|
|
359
|
+
TestAuthHandler.Register(services);
|
|
360
|
+
}
|
|
361
|
+
// ... clears both env vars again in Dispose
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
This is only safe because the test assembly disables collection parallelization — no other test's
|
|
366
|
+
host can boot while the variable is set, or it would leak into an unrelated test run.
|
|
367
|
+
|
|
368
|
+
`TestAuthHandler` (`Minimal.App.TestSupport/TestAuthHandler.cs`) replaces the real JWT bearer scheme
|
|
369
|
+
so a request can be authenticated without a live token. It issues a fixed name/subject and a scope
|
|
370
|
+
claim built from every `ProductScopes` entry by default, overridable per request via a header:
|
|
371
|
+
|
|
372
|
+
```csharp
|
|
373
|
+
public const string ScopesHeaderName = "X-Test-Scopes";
|
|
374
|
+
public static readonly string DefaultScopes = string.Join(' ', ProductScopes.All);
|
|
375
|
+
|
|
376
|
+
var identity = new ClaimsIdentity(
|
|
377
|
+
[
|
|
378
|
+
new Claim(ClaimTypes.Name, CallerName),
|
|
379
|
+
new Claim(ClaimTypes.NameIdentifier, CallerProfileId.ToString()),
|
|
380
|
+
new Claim("scp", scopes)
|
|
381
|
+
],
|
|
382
|
+
SchemeName);
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
To write an auth-on test for a new feature: add a fixture that mirrors `AuthOnApiFixture` (env vars
|
|
386
|
+
in the constructor, cleared in `Dispose`, `TestAuthHandler.Register(services)` in
|
|
387
|
+
`ConfigureTestServices`), then assert against `TestAuthHandler.CallerName` /
|
|
388
|
+
`TestAuthHandler.CallerProfileId` the same way `PurchaseOrderSecurityTests`/`ProductSecurityTests`
|
|
389
|
+
do. For a test that needs two distinct callers in one host (row-isolation tests),
|
|
390
|
+
`Minimal.App.TestSupport/MultiSubjectAuthHandler.cs` reads the subject from a request header instead
|
|
391
|
+
of a fixed constant — see `AuthOnMultiSubjectApiFixture` and `ProductOwnershipIsolationTests`. For a
|
|
392
|
+
caller authenticated with no name claim at all, see `AuthOnNoNameClaimApiFixture`. For a fixed-tenant
|
|
393
|
+
`OwnedBy` with a still-distinct acting `CreatedBy`, see `AuthOnFixedTenantApiFixture`.
|
|
394
|
+
`RequireAuthorizationPlusDemoApiFixture` sets **both** flags on and, unlike every other fixture in
|
|
395
|
+
that folder, does not implement `IAsyncLifetime` or reset the database — building the host is itself
|
|
396
|
+
the thing under test, and it is expected to fail start-up.
|
|
397
|
+
|
|
398
|
+
## Common mistakes
|
|
399
|
+
|
|
400
|
+
- **What you might expect:** calling `.RequireAuthorization(scope)` unconditionally on a route.
|
|
401
|
+
**What actually happens:** with `RequireAuthorization` off, no policies were ever registered, so
|
|
402
|
+
the call throws at request time. Always gate it on `FeatureOptions.RequireAuthorization`, as
|
|
403
|
+
`ProductV1Endpoint` does.
|
|
404
|
+
- **What you might expect:** giving a generated `[CrudAction]` a parameter meant to auto-populate
|
|
405
|
+
from the caller. **What actually happens:** it becomes an ordinary, caller-settable bound property
|
|
406
|
+
— there is no `[FromClaim]` seam on a generated request.
|
|
407
|
+
- **What you might expect:** `EnableDemoAuthentication` is safe to leave on alongside
|
|
408
|
+
`RequireAuthorization` for a "belt and suspenders" local setup. **What actually happens:**
|
|
409
|
+
`AddAppConfig` throws at start-up — the two are hard mutually exclusive.
|
|
410
|
+
- **What you might expect:** an unauthenticated caller's write just gets attributed to
|
|
411
|
+
`SharedConsts.SystemAccount` and proceeds. **What actually happens:** only true for a context with
|
|
412
|
+
no `IDataOwnerProvider` friction; once ownership stamping is wired (as it is for `Product`), a
|
|
413
|
+
caller whose subject claim cannot be resolved gets a 403 `OwnershipRequiredException`, not a
|
|
414
|
+
silent system-account write.
|
|
415
|
+
- **What you might expect:** a config-file override in a `WebApplicationFactory` subclass can flip
|
|
416
|
+
`RequireAuthorization` for a single test class. **What actually happens:** `Program.cs` has already
|
|
417
|
+
bound `FeatureOptions` before that override merges in — only an environment variable set before the
|
|
418
|
+
host builds (in the fixture's constructor) takes effect.
|