Who can do what
Your server works out who is calling. The schema says what they may do. Rayfold enforces those rules on every request, before and while it runs, so resolvers never repeat a permission check and a new field cannot forget one.
Who is calling
// Stands in for real authentication: check your session cookie or JWT here instead.
export function viewerFrom(authorization: string | undefined): Viewer | null {
if (authorization === "Bearer customer") return { id: "u1", role: "customer" };
if (authorization === "Bearer staff") return { id: "s1", role: "staff" };
return null;
}// Two fixed tokens stand in for real authentication.
private val demoViewers = mapOf(
"Bearer customer" to buildJsonObject { put("id", "u1"); put("role", "customer") },
"Bearer staff" to buildJsonObject { put("id", "s1"); put("role", "staff") },
)
/** The viewer the schema's policies see as `viewer`, or null for an anonymous request. */
fun viewerOf(exchange: HttpExchange): JsonElement =
demoViewers[exchange.requestHeaders.getFirst("Authorization")] ?: JsonNull// Two fixed tokens stand in for real authentication.
private static final Map<String, Map<String, String>> DEMO_VIEWERS = Map.of(
"Bearer customer", Map.of("id", "u1", "role", "customer"),
"Bearer staff", Map.of("id", "s1", "role", "staff"));
/** The viewer the schema's policies see as {@code viewer}, or null for an anonymous request. */
static Map<String, String> viewerOf(HttpExchange exchange) {
String authorization = exchange.getRequestHeaders().getFirst("Authorization");
return authorization == null ? null : DEMO_VIEWERS.get(authorization);
}@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(requests -> requests.anyRequest().permitAll())
.oauth2ResourceServer(oauth2 -> oauth2.opaqueToken(Customizer.withDefaults()))
// Rayfold checks the Origin and content type of every request that can change data itself
.csrf(csrf -> csrf.ignoringRequestMatchers("/rayfold/**"))
.build();
}
// Two fixed tokens stand in for real authentication; a real application asks its authorization server here.
// The starter turns the signed-in principal into the viewer: its name is viewer.id and its first role viewer.role.
@Bean
OpaqueTokenIntrospector tokens() {
Map<String, OAuth2AuthenticatedPrincipal> principals = Map.of(
"customer", principal("u1", "customer"),
"staff", principal("s1", "staff"));
return token -> Optional.ofNullable(principals.get(token))
.orElseThrow(() -> new BadOpaqueTokenException("Unknown token"));
}
private static OAuth2AuthenticatedPrincipal principal(String id, String role) {
return new DefaultOAuth2AuthenticatedPrincipal(id, Map.of("sub", id), AuthorityUtils.createAuthorityList("ROLE_" + role));
}
}The function gets the request and returns the viewer: any object you like, or null for someone who is not signed in. In a real server it reads a session or verifies a token. In Spring Boot you do not write it: the starter builds the viewer from Spring Security's signed-in principal. The server hands the function every request:
export function bookshopHttp(server: RayfoldServer): Server {
const endpoint = createHttpHandler(server, { viewer: (req) => viewerFrom(req.headers.authorization) });
const explorer = createExplorerHandler({ endpoint: "/rayfold", title: "Bookshop" });
return createServer((req, res) => {
if (explorer(req, res)) return;
if (req.url?.startsWith("/rayfold")) return void endpoint(req, res);
res.writeHead(404).end();
});
}fun startServer(port: Int, store: Store = Store()): HttpServer {
val schema = SchemaText.load(resourceText("/bookshop.rayfold")).ir
val server = RayfoldServer(schema, resolvers(store))
val options = HttpOptions(explorer = true, explorerTitle = "Bookshop")
return RayfoldHttp(server, options, ::viewerOf).start(port)
}public static HttpServer start(int port, Store store) throws IOException {
return Rayfold.http(server(store))
.viewer(Bookshop::viewerOf)
.explorer("Bookshop")
.start(port);
}Rules in the schema
entity Book @cache(maxAge: 60s, scope: public) {
id: ID
title: String
stock: Int
author: Author
costPrice: Decimal? @allow(read: viewer.role == "staff")
}
command buy(bookId: ID, qty: Int = 1 @range(min: 1, max: 10)): Book
throws OutOfStock
@allow(write: viewer != null)
command restock(bookId: ID, qty: Int @range(min: 1, max: 1000)): Book
@allow(write: viewer.role == "staff")readgoverns queries and the reading of fields, including fields of a command's result.writegoverns commands.- A rule can sit on an operation, a type or a field. A field is readable only if every level allows it, and
@denyoverrides@allow. - Rules are expressions over
viewer,this(the object being read) andargs, with==,!=,<,>,in,&&,||and!. That makes rules about rows natural:
entity Order @allow(read: viewer.id == customerId || viewer.role == "admin") {
id: ID
customerId: ID
total: Decimal
}An expression that cannot be evaluated, such as comparing text with a boolean, fails closed: @allow does not allow.
What a caller sees when a rule says no
| When the rule refuses | The caller gets |
|---|---|
| An operation, and nobody is signed in | unauthenticated: Sign in to access buy() |
| An operation | permission_denied: Not allowed to access restock() |
| A field the shape asks for | permission_denied with "path": "costPrice", and the operation fails as a whole |
A field the shape asks for, marked @partial | null for that field, and an entry in the frame's errors |
| A field that is only in the default view | the field is left out, with no error |
An entity at a position that may be null | null, exactly as if it did not exist |
The last row matters: a caller cannot find out that an order exists by being refused it.
Rules in the database too
A rule that only uses viewer, args, literals and plain fields of this can be handed to the loader as a filter, so a list query never loads rows the viewer may not see. Loaders that do not use it still get correct results: the runtime filters afterwards. The Postgres adapter turns these rules into SQL.
Limits per caller
The server works out what each batch can cost before it runs and refuses one over the caller's budget with resource_exhausted. See what a query costs.
Narrow access for agents and services
A capability token lets an AI agent or another service act for a user in a limited way, without holding the user's credentials: it names the viewer, the operations it may call and when it expires, and it is signed. A token can be narrowed further before being handed on, never widened. The authorization chapter describes the format.
Browsers
- A Rayfold server refuses commands sent by a browser from an origin it does not know, which stops another website from acting for your users. List your web app's origin in
allowedOrigins; the React guide shows it. - It reads only JSON and its binary format as request bodies, so a plain HTML form on another site cannot submit to it.
The security chapter lists every default.
Next
- Keep a query open and receive changes: Live updates.
- The errors a refused request comes back with: Errors.