5. Decide who can do what
Our back office works, but it’s wide open: anyone who can reach it can register authors and add books. The last thing a real app needs is to decide who is allowed. We’ll add role-based authorization — and the nice part is that it’s declarative, so none of our Handle() methods or read models change. The rule lives at the boundary, where it belongs.
Prerequisites
Section titled “Prerequisites”Continue with the standalone host from chapter 1; for TypeScript use the standalone MongoDB host, retaining its tenant resolver when adding the development header handler. Add global using Cratis.Arc.Authorization; to GlobalUsings.cs on C#; on the JVM, import io.cratis.arc.authorization.Roles in the files that use it. The TypeScript snippets import roles from @cratis/arc.core. This chapter uses the role name Librarian; it must match an authorization role on the authenticated principal. No identity-details provider is required.
On C#, the first excerpt below also declares a one-member LibraryRole enum; put it in its own file, src/LibraryRole.cs, under namespace Library; so every feature folder can use it. The C# excerpts pass nameof(LibraryRole.Librarian), which is still the string "Librarian". Arc’s ARC0011 analyzer warns about string literals in [Roles]; with nameof, a mistyped role name is a compile error instead of a silent denial. The JVM and TypeScript snippets pass the same name as a string.
Gate the commands
Section titled “Gate the commands”Authorization is a cross-cutting concern. You don’t want if (user.IsLibrarian) checks scattered through your logic — you want one declaration at the edge. Put [Roles(...)] — @Roles(...) on the JVM — on the command (@roles(...) on TypeScript), and Arc checks the caller’s roles before the command runs. The excerpts below show the document-store branch; on the relational branch retain your existing handlers and queries while replacing the JVM’s @AllowAnonymous on the command with @Roles("Librarian"). Do not keep both on the same declaration:
- Require a role to change the catalog. Only a librarian may register authors or add books. The TypeScript prerequisite host also exposes
RenameAuthor, so its tab protects that command too:
public enum LibraryRole{ Librarian}
[Command][Roles(nameof(LibraryRole.Librarian))]public record RegisterAuthor(AuthorId Id, AuthorName Name){ public Task Handle(IMongoCollection<Author> authors) => authors.InsertOneAsync(new Author(Id, Name));}@Command@Roles("Librarian")public record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); }}@Command@Roles("Librarian")data class RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) }}import { field } from '@cratis/fundamentals';import { command, commandReadModel, inject, key, roles } from '@cratis/arc.core';
@command()@roles('Librarian')export class RegisterAuthor { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
@inject(AuthorRepository) handle(authors: AuthorRepository): Promise<void> { return authors.save({ id: this.id, name: this.name }); }}
@command()@roles('Librarian')export class RenameAuthor { @field(AuthorId) @key() id!: AuthorId; @field(AuthorName) newName!: AuthorName;
@inject(commandReadModel(Author), AuthorRepository) async handle(author: Author, authors: AuthorRepository): Promise<void> { author.name = this.newName; await authors.save(author); }}The same declaration goes on AddBook; on the JVM, replace its @AllowAnonymous annotation rather than stacking the two. The .NET and JVM chapter-1 projects do not include RenameAuthor; if you added it from the standalone-slice example, apply the same role to it (replacing @AllowAnonymous on the JVM). Everything inside the handler stays exactly as it was — the gate is applied around it.
Gate the queries
Section titled “Gate the queries”The read side deserves the same protection — a public catalog might be fine, but an internal audit list is not. Query methods take the same declaration. Apply it to both AllAuthors and BooksForAuthor to keep this tutorial’s catalog private. On the JVM, remove the chapter-1 @AllowAnonymous class annotation from Author and the chapter-3 one from Book, then add @Roles("Librarian") to each query operation. Otherwise a query on the same read model without its own role declaration would remain public:
[ReadModel]public record Author(AuthorId Id, AuthorName Name){ [Roles(nameof(LibraryRole.Librarian))] public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> authors) => authors.Observe();}@ReadModelpublic record Author(AuthorId id, AuthorName name) { @Roles("Librarian") public static Flow.Publisher<List<Author>> allAuthors(@FromServices MongoObservableQuery queries) { return queries.observePublisher(Author.class); }}@ReadModeldata class Author(val id: AuthorId, val name: AuthorName) { companion object { @JvmStatic @Roles("Librarian") fun allAuthors(@FromServices queries: MongoObservableQuery): Flow<List<Author>> = queries.observe<Author>() }}import { field } from '@cratis/fundamentals';import { query, readModel, roles, service, type ObservableSource } from '@cratis/arc.core';
@readModel()export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
@roles('Librarian') @query({ observable: true }, service(AuthorRepository)) static allAuthors(authors: AuthorRepository): ObservableSource<Author[]> { return authors.observeAll(); }}On all three backends the operation’s own declaration wins over the class’s. That is what lets a protected read model still expose one open query — on the JVM an operation that declares @AllowAnonymous, @Authorize, or @Roles replaces the class default entirely rather than merging with it, so narrowing is exact. See authorization on artifacts and operations.
Where the roles come from
Section titled “Where the roles come from”Arc checks ClaimsPrincipal.IsInRole("Librarian"). Configure your authentication scheme’s role claims accordingly. An application detail such as LibraryIdentity.Role does not grant that role: IProvideIdentityDetails enriches client/domain context, and its rejected result controls /.cratis/me, not every operation.
The JVM asks the same question of an ArcPrincipal: a caller holds Librarian when one of its roles is that name, which is what a Spring Security ROLE_Librarian authority becomes once Arc drops the prefix. On TypeScript, the microsoftIdentityPlatform() development handler reads Librarian from the header principal’s userRoles. An IdentityDetailsProvider plays the same enriching, non-granting role there as IProvideIdentityDetails does here.
For this loopback-only development checkpoint, select the trusted-header adapter for your backend before building the host. The TypeScript builder registers its header handler directly alongside the MongoDB host’s tenant resolver; the JVM bridge needs an explicitly verified trusted ingress, not just a property toggle:
if (builder.Environment.IsDevelopment()){ builder.Services.AddMicrosoftIdentityPlatformIdentityAuthentication(); builder.Services.Configure<ArcOptions>(options => options.TrustForwardedIdentityHeaders = true);}Java does not support this workflow yet: the ASP.NET Core adapter is .NET-only. On the JVM, Arc.Kotlin's optional platform identity bridge reads the same x-ms-client-principal headers, but requires cratis.arc.platform-identity.enabled=true and an ArcPlatformIdentityTrust bean that verifies your ingress strips caller-supplied headers. There is no safe generic trust bean to copy: see /arc/backend/kotlin/guides/security/. For another local identity mechanism, register io.cratis.arc.authentication.AsyncAuthenticationHandler.Kotlin does not support this workflow yet: the ASP.NET Core adapter is .NET-only. On the JVM, Arc.Kotlin's optional platform identity bridge reads the same x-ms-client-principal headers, but requires cratis.arc.platform-identity.enabled=true and an ArcPlatformIdentityTrust bean that verifies your ingress strips caller-supplied headers. There is no safe generic trust bean to copy: see /arc/backend/kotlin/guides/security/. For another local identity mechanism, register io.cratis.arc.authentication.AuthenticationHandler.import { ArcApplication, microsoftIdentityPlatform } from '@cratis/arc.core';
const development = process.env.NODE_ENV === 'development';const builder = ArcApplication.createBuilder({ tenancy: { resolve: () => 'default' }, development, authentication: development ? [microsoftIdentityPlatform()] : []});On .NET, before app.UseCratisArc(), add the authentication middleware. The TypeScript builder’s authentication option already covers this step:
if (app.Environment.IsDevelopment()){ app.UseAuthentication();}Java does not support this workflow yet: ASP.NET Core's UseAuthentication is .NET-only. Spring Boot's security chain and Arc.Kotlin's io.cratis.arc.authentication.AsyncAuthenticationHandler beans handle authentication on the JVM; see /arc/backend/kotlin/guides/security/ for the platform filter-chain and handler setup.Kotlin does not support this workflow yet: ASP.NET Core's UseAuthentication is .NET-only. Spring Boot's security chain and Arc.Kotlin's io.cratis.arc.authentication.AuthenticationHandler beans handle authentication on the JVM; see /arc/backend/kotlin/guides/security/ for the platform filter-chain and handler setup.TypeScript does not support this workflow yet: ASP.NET Core's separate UseAuthentication middleware step is .NET-only. The Node host's ArcApplication.createBuilder authentication option above already registers microsoftIdentityPlatform() for local development; see /arc/backend/typescript/identity/local-development/ for its trust boundary.Keep the host bound to loopback for this development checkpoint: http://localhost:5000 on C#, or http://127.0.0.1:3000 on TypeScript. Spring Boot listens on all interfaces by default; JVM readers must set server.address=127.0.0.1 in local configuration before using http://127.0.0.1:8080. A port number alone does not restrict who can reach the server. Follow Generate a local principal on C# or Simulate a signed-in user locally on TypeScript to send the three x-ms-client-principal headers through the local Vite proxy. In the principal JSON, include "Librarian" in userRoles, then Base64-encode it locally. Restrict the browser header override to your localhost app URLs. On the JVM, use an authenticated ArcPrincipal carrying that role through your own handler or verified ingress (see below).
This adapter does not validate bearer-token signatures, issuer, or audience. It trusts the headers. Production must instead use a properly configured authentication scheme or an authenticated trusted ingress that replaces spoofable headers and prevents direct backend access. Never expose this local header-spoofing fixture publicly.
Verify permissions, not just UI visibility
Section titled “Verify permissions, not just UI visibility”Rebuild and restart the C# checkpoint with -e ASPNETCORE_ENVIRONMENT=Development, or start the TypeScript host with NODE_ENV=development on port 3000, then test these separately. The header adapters run only in development: in Production they are not registered and even the Librarian header case is rejected. The checks are the same on all three backends; only the way you present a principal differs — the headers for the .NET and TypeScript fixtures, or whatever your JVM authentication handler accepts:
- Without identity headers, protected commands and queries must reject access.
- With a valid local principal but no
Librarianrole, they must still reject access. - With
LibrarianinuserRoles, list authors and register one successfully. - Remove the role again. Hiding a button is optional UI behavior; a direct operation call must still fail.
Inspect isAuthorized on the command/query result. Authentication middleware may reject a request before an Arc result is created, so also inspect the HTTP response. The recap below assumes you observed all three principal cases, not merely added attributes.
For production authentication and domain details, continue with Identity and Authorizing commands and queries, the JVM security guide, or TypeScript’s authentication guide.
Look back at what you built
Section titled “Look back at what you built”Five chapters ago we had nothing. Now we have a real full-stack library back office, running over a plain database — and you’ve used every part of the Arc loop and know why each one is shaped the way it is:
- Chapter 1 — a full-stack slice: a
[Command]/@Commandwith a handler that writes, a[ReadModel]/@ReadModelwith its query, and a React screen calling both through generated, typed proxies — the same TypeScript whichever backend generated it. - Chapter 2 — trust: a
ConceptValidatoron the value type and aCommandValidatorbusiness rule, both surfaced in the UI through the generated proxy. - Chapter 3 — relationships: a second slice and a filtered query that reads one author’s catalog, no join.
- Chapter 4 — life: observable queries as standing subscriptions, database to browser.
- Chapter 5 — authorization:
[Roles(...)]at the boundary, on both commands and queries (with@Roleson the JVM and@roleson TypeScript), tied to the signed-in user.
The thing worth taking with you is the loop itself: something happens (a command) → it writes state → that state is served (a query) → and React calls all of it, typed end to end. Every feature you build from here is another turn of that loop in its own folder. You never go back and edit the last one to add the next.
And notice the boundary you proved: Arc did not need an event store for this CQRS loop. That is not an argument against event sourcing. For information systems, we usually reach for Chronicle as the default because history pays for itself. The point is architectural independence: CQRS can run over a plain database, and event sourcing can run without Arc, but they fit together extremely well.
From here, build another feature the same way, or take the next layer up: Components turns these generated commands and queries into higher-level screens, and the Cratis Stack tour shows where Arc fits with the rest of the platform.