Understanding identity and access
Three questions run through every real application: who is this user, what are they allowed to do, and — if you serve more than one customer — whose data are they allowed to see. Arc supplies hooks for all three, but authentication, operation authorization, tenant membership, and storage isolation remain distinct responsibilities. This page is the mental model; each section links to the reference that goes deeper.
Who is the user — identity, enriched once
Section titled “Who is the user — identity, enriched once”Authentication establishes a ClaimsPrincipal — an id, a name, and claims, including any authorization roles. Your application may also need display preferences, membership information, or other domain details. Arc lets you enrich that identity without confusing the details with authoritative role claims.
Implement IProvideIdentityDetails<TDetails>. During identity resolution, Arc calls it with an IdentityProviderContext, not the raw token. This illustrative MongoDB fragment assumes application-owned Member and LibraryIdentity types and the identity/provider imports; it controls the identity-details result, not access to every command:
public class LibraryIdentityProvider(IMongoCollection<Member> members) : IProvideIdentityDetails<LibraryIdentity>{ public async Task<IdentityDetails> Provide(IdentityProviderContext context) { // Look the user up in your own data, keyed by the provider's id. var member = await members.Find(m => m.Subject == context.Id.Value).FirstOrDefaultAsync(); if (member is null) return new IdentityDetails(false, LibraryIdentity.None); // /.cratis/me rejects this result
var identity = new LibraryIdentity(member.Id, member.Role, member.Name); return new IdentityDetails(true, identity); }}public record LibraryIdentity(String memberId, String role, String displayName) {}
@Beanpublic IdentityDetailsProvider<LibraryIdentity> identityDetails(MemberRepository members) { return new AsyncIdentityDetailsProviderAdapter<>(new AsyncIdentityDetailsProvider<LibraryIdentity>() { @Override public Class<LibraryIdentity> getDetailsType() { return LibraryIdentity.class; }
// Look the user up in your own data, keyed by the provider's id. @Override public CompletionStage<IdentityDetails<LibraryIdentity>> provide(IdentityProviderContext context) { var member = members.bySubject(context.getId()); if (member == null) { return CompletableFuture.completedFuture( new IdentityDetails<>(false, new LibraryIdentity("", "", ""))); }
return CompletableFuture.completedFuture(new IdentityDetails<>( true, new LibraryIdentity(member.id(), member.role(), member.name()))); } });}data class LibraryIdentity(val memberId: String, val role: String, val displayName: String)
@Beanfun identityDetails(members: MemberRepository): IdentityDetailsProvider<LibraryIdentity> = object : IdentityDetailsProvider<LibraryIdentity> { override val detailsType = LibraryIdentity::class.java
// Look the user up in your own data, keyed by the provider's id. override suspend fun provide(context: IdentityProviderContext): IdentityDetails<LibraryIdentity> { val member = members.bySubject(context.id) ?: return IdentityDetails(isUserAuthorized = false, details = LibraryIdentity("", "", ""))
return IdentityDetails( isUserAuthorized = true, details = LibraryIdentity(member.id, member.role, member.name) ) } }import { ArcApplication, currentServices, type IdentityDetailsProvider } from '@cratis/arc.core';import { z } from 'zod';
const libraryIdentity = z.object({ memberId: z.string(), role: z.string(), displayName: z.string() });
const identityDetails: IdentityDetailsProvider = { schema: libraryIdentity, provide: async principal => { // Look the user up in your own data, keyed by the principal's id. const members = await currentServices().resolve(MemberRepository); const member = await members.bySubject(principal.id); if (!member) return undefined; // /.cratis/me answers 403
return { memberId: member.id, role: member.role, displayName: member.name }; }};
const builder = ArcApplication.createBuilder({ identityDetails });builder.services.addScoped(MemberRepository, MongoMemberRepository);Two things fall out of this design:
- Identity-details rejection.
IsUserAuthorized: falsemakes/.cratis/mereturn403for that result. It is not a global operation gate: the command/query authorization evaluator checks the principal and operation attributes independently. Protect actual operations against blocked users or expired subscriptions; hiding the UI or rejecting identity enrichment is insufficient. - One identity, backend to browser. The details you return are served from
/.cratis/meand consumed, typed, on the React side — the same way commands and queries are. You enrich identity once and read it everywhere. (Details: Identity, contracts, and on the frontend.)
What can they do — authorize commands and queries
Section titled “What can they do — authorize commands and queries”Gate what they can do with attributes on the command or query. This standalone MongoDB excerpt uses the tutorial’s domain types and provider setup plus Cratis.Arc.Authorization imports. LibraryRole is an application enum that names roles for nameof, which Arc’s ARC0011 analyzer prefers over string literals:
public enum LibraryRole { Librarian } // the application's role names, in one place
[Roles(nameof(LibraryRole.Librarian))] // only a Librarian may register an author[Command]public record RegisterAuthor(AuthorId Id, AuthorName Name){ public Task Handle(IMongoCollection<Author> authors) => authors.InsertOneAsync(new Author(Id, Name));}
[ReadModel]public record Author(AuthorId Id, AuthorName Name){ [AllowAnonymous] // the public catalog is open to everyone public static ISubject<IEnumerable<Author>> AllAuthors(IMongoCollection<Author> c) => c.Observe();}@Roles("Librarian") // only a Librarian may register an author@Commandpublic record RegisterAuthor(AuthorId id, AuthorName name) { public void handle(@FromServices AuthorRepository authors) { authors.save(new Author(id, name)); }}
@ReadModelpublic record Author(AuthorId id, AuthorName name) { @AllowAnonymous // the public catalog is open to everyone public static Flow.Publisher<List<Author>> allAuthors(@FromServices AuthorRepository authors) { return authors.observeAll(); }}@Roles("Librarian") // only a Librarian may register an author@Commandclass RegisterAuthor(val id: AuthorId, val name: AuthorName) { suspend fun handle(@FromServices authors: AuthorRepository) { authors.save(Author(id, name)) }}
@ReadModeldata class Author(val id: AuthorId, val name: AuthorName) { companion object { @JvmStatic @AllowAnonymous // the public catalog is open to everyone fun allAuthors(@FromServices authors: AuthorRepository): Flow<List<Author>> = authors.observeAll() }}import { field } from '@cratis/fundamentals';import { allowAnonymous, command, inject, query, readModel, roles, service, type ObservableSource } from '@cratis/arc.core';
@roles('Librarian') // only a Librarian may register an author@command()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 }); }}
@readModel()export class Author { @field(AuthorId) id!: AuthorId; @field(AuthorName) name!: AuthorName;
@allowAnonymous() // the public catalog is open to everyone @query({ observable: true }, service(AuthorRepository)) static allAuthors(authors: AuthorRepository): ObservableSource<Author[]> { return authors.observeAll(); }}[Authorize]requires an authenticated user;[Roles("Librarian", "Admin")]requires at least one of the listed roles;[AllowAnonymous]opens a specific endpoint back up.- Apply them at the class level to cover everything and override per method for exceptions — secure by default, opt out where you mean it.
- A failure is automatic:
401if authentication stops the request before Arc runs,403if Arc’s command or query pipeline rejects the signed-in user. Pipeline authorization failures are surfaced in the generated command/query result asisAuthorized: false, so the UI can react.
When a rule depends on a specific row — “you can only edit your own profile” — enforce it server-side against the trusted principal and that row, using your authorization extension or an explicit operation guard. An ordinary validation rejection is not the same result as an authorization rejection. (Full matrix, claims access, and inheritance rules: Authorization.)
Whose data — multi-tenancy
Section titled “Whose data — multi-tenancy”If one deployment serves many customers, who and what aren’t enough — you need whose. Arc treats the tenant as a first-class part of every request: it resolves a tenant id, keeps it in context for the whole request, and points tenant-aware stores at the right data.
The shape of it:
- A resolver selects a tenant id from a claim, header, or other configured source. A header resolver simply reads the value; running it after authentication does not prove membership.
- Your trusted ingress or application authorizes the user’s membership in the selected tenant before serving tenant data. The id is then available through tenant context.
- Configure and verify tenant-aware storage separately — database routing, partitions, or application query filters. Tenant selection alone neither isolates data nor proves every command/query uses the selected store. Test both cross-tenant membership rejection and storage separation.
Set it up with tenant resolvers, reach it through tenant context, and isolate storage with database resolvers — the tenancy overview ties them together.
Where to go next
Section titled “Where to go next”- Identity — the provider flow, contracts, and multi-service topologies in full.
- Authorization — every attribute, the inheritance rules, and claims-based custom logic.
- Tenancy — resolvers, context, and per-tenant data isolation.
- Microsoft Identity headers — accepting the
x-ms-client-principalfamily of HTTP headers from your ingress. These are unsigned assertions accepted from a trusted ingress, not bearer tokens validated by Arc. The ingress must replace spoofable headers and prevent direct backend access, and the host has to opt in withTrustForwardedIdentityHeadersbefore Arc trusts them. For local-only fixtures, see Generate a principal.