Skip to content

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

ts
// 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;
}
kotlin
// 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
java
// 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);
}
java
@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:

ts
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();
  });
}
kotlin
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)
}
java
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

rayfold
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")
  • read governs queries and the reading of fields, including fields of a command's result. write governs commands.
  • A rule can sit on an operation, a type or a field. A field is readable only if every level allows it, and @deny overrides @allow.
  • Rules are expressions over viewer, this (the object being read) and args, with ==, !=, <, >, in, &&, || and !. That makes rules about rows natural:
rayfold
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 refusesThe caller gets
An operation, and nobody is signed inunauthenticated: Sign in to access buy()
An operationpermission_denied: Not allowed to access restock()
A field the shape asks forpermission_denied with "path": "costPrice", and the operation fails as a whole
A field the shape asks for, marked @partialnull for that field, and an entry in the frame's errors
A field that is only in the default viewthe field is left out, with no error
An entity at a position that may be nullnull, 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.

Released under the Apache-2.0 license.