@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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +22 -0
  2. package/.claude-plugin/plugin.json +38 -0
  3. package/LICENSE +21 -0
  4. package/README.md +261 -0
  5. package/agents/dknet-architect.md +49 -0
  6. package/agents/dknet-bdd-engineer.md +62 -0
  7. package/agents/dknet-implementer.md +75 -0
  8. package/package.json +52 -0
  9. package/plugin.json +26 -0
  10. package/skills/README.md +47 -0
  11. package/skills/dknet-auth-and-ownership/SKILL.md +418 -0
  12. package/skills/dknet-bdd-tests/SKILL.md +355 -0
  13. package/skills/dknet-bdd-tests/checklist.md +39 -0
  14. package/skills/dknet-crud/SKILL.md +483 -0
  15. package/skills/dknet-ddd-principles/SKILL.md +87 -0
  16. package/skills/dknet-docs/SKILL.md +296 -0
  17. package/skills/dknet-docs/checklist.md +58 -0
  18. package/skills/dknet-docs/templates/README-template.md +68 -0
  19. package/skills/dknet-docs/templates/api-reference-template.md +275 -0
  20. package/skills/dknet-docs/templates/architecture-template.md +166 -0
  21. package/skills/dknet-docs/templates/data-model-template.md +99 -0
  22. package/skills/dknet-docs/templates/events-template.md +155 -0
  23. package/skills/dknet-dto-mapping/SKILL.md +278 -0
  24. package/skills/dknet-efcore-config/SKILL.md +379 -0
  25. package/skills/dknet-endpoint/SKILL.md +458 -0
  26. package/skills/dknet-entity/SKILL.md +483 -0
  27. package/skills/dknet-feature/SKILL.md +139 -0
  28. package/skills/dknet-feature-lifecycle/SKILL.md +144 -0
  29. package/skills/dknet-feature-remove/SKILL.md +131 -0
  30. package/skills/dknet-messaging-events/SKILL.md +395 -0
  31. package/skills/dknet-package-adoption/SKILL.md +252 -0
  32. package/skills/dknet-platform-config/SKILL.md +342 -0
  33. package/skills/dknet-project-structure/SKILL.md +148 -0
  34. package/skills/dknet-queries-specs/SKILL.md +330 -0
  35. package/skills/dknet-scaffold/SKILL.md +209 -0
  36. package/skills/dknet-unit-tests/SKILL.md +382 -0
@@ -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.