Zig 0.17.0

Access policies

.authenticated and .roles say who may call a route. Some rules are about the thing the route works on: a post is changed by who wrote it, whoever else is signed in. That is a policy.

The code on this page is from Posts, the example app in examples/posts (source). Anyone with an account writes posts there, and each account changes and deletes its own.

The rules, in one file

A post keeps the account that wrote it, in a column:

sql
-- migrate:up
-- Who wrote the post: the account, not only the name shown. Empty for the
-- sample posts and for the ones the API creates: those belong to nobody.
ALTER TABLE posts ADD COLUMN user_id INTEGER REFERENCES users(id);

-- migrate:down
ALTER TABLE posts DROP COLUMN user_id;

The rules about posts are in src/features/posts/policy.zig:

zig
pub const Posts = spider.policySet(model.Post, .{
    .name = "post",
    .load = load,
    .rules = .{
        .update = isAuthor,
        .delete = isAuthor,
    },
});

/// The post named by the :id of the path. Null (no such post, or an id
/// that is not a number) is the 404 page.
pub fn load(c: *spider.Ctx) !?model.Post {
    const text = c.params.get("id") orelse return null;
    const id = std.fmt.parseInt(i64, text, 10) catch return null;
    return repository.find(c.arena, id);
}

/// A post is changed by the account that wrote it. The sample posts have
/// no account (`user_id` is 0): nobody changes them.
fn isAuthor(c: *spider.Ctx, post: *const model.Post) bool {
    const signed_in = signedInId(c) orelse return false;
    return post.user_id == signed_in;
}

/// The id of the account that is signed in: the session carries it as text.
pub fn signedInId(c: *spider.Ctx) ?i64 {
    return std.fmt.parseInt(i64, c.userId() orelse return null, 10) catch null;
}

spider.policySet takes the type the rules are about, a name, a function that loads one from the request, and the rules. A rule is a function that receives the request and the loaded post and answers yes or no. Two actions can share a rule, as .update and .delete do here.

On a route

A route names the rule it needs:

zig
/// Routes of the posts feature. src/main.zig mounts them with
/// `server.mountFeatures(features)`: every pub fn here with no parameters
/// that returns a spider.Group. Paths are relative to the group prefix.
///
/// Reading is for everyone: `defaults` makes the routes public. Writing
/// needs a login: those routes say `.authenticated`, which replaces the
/// default for them. Changing or deleting a post is for its author: those
/// routes name a rule of src/features/posts/policy.zig, which also needs
/// a login.
pub fn build() spider.Group {
    const signed_in = .{ .authenticated = true };
    var g = spider.Group.init("/posts");
    _ = g
        .defaults(.{ .public = true })
        .get("", controller.index, .{})
        .get("/export.csv", controller.exportCsv, .{})
        .get("/new", controller.new, signed_in)
        .post("/create", controller.create, signed_in)
        .get("/:id", controller.show, .{})
        .get("/:id/edit", controller.edit, .{ .policy = Posts.route(.update) })
        .post("/:id/update", controller.update, .{ .policy = Posts.route(.update) })
        .get("/:id/delete", controller.confirmDelete, .{ .policy = Posts.route(.delete) })
        .post("/:id/delete", controller.delete, .{ .policy = Posts.route(.delete) });
    return g;
}

Before the handler runs, Spider does this, in order:

  1. Nobody signed in: 401. The post is not even loaded. In Posts that becomes a redirect to the sign-in page.
  2. load gives null: 404.
  3. The rule says no: 403.
  4. The rule says yes: the handler runs.

So a handler behind a policy does not check anything, and does not load the post again. It asks for it as a parameter:

zig
const Mine = spider.Loaded(model.Post);

pub fn edit(c: *spider.Ctx, mine: Mine) !Response {
    const post = mine.value;
    return form(c, post.id, .{ .title = post.title, .body = post.body, .author = post.author }, .{});
}

pub fn update(c: *spider.Ctx, mine: Mine) !Response {
    const post = mine.value;
    var input = (try c.parseForm(model.Input)).trimmed();
    input.author = post.author; // an edit does not change who wrote it
    const errors = model.validate(input);
    if (errors.any()) return form(c, post.id, input, errors);

    try repository.update(c.arena, post.id, input);
    return saved(c, post.id);
}

spider.Loaded(model.Post) is the post the policy loaded and allowed; .value points at it.

In a page

A page that anyone reads still has to decide what to offer. The same rule answers, with can:

zig
pub fn show(c: *spider.Ctx) !Response {
    const post = (try policy.load(c)) orelse return error.NotFound;
    return c.view("posts/show", .{
        .title = post.title,
        .post = try present.card(c.arena, post),
        // The same rule the routes use decides whether the buttons show.
        .can_edit = try Posts.can(c, .update, &post),
        // The body is Markdown. Spider's renderer escapes what the author
        // typed, so its HTML can go into the page as it is.
        .body = spider.RawHtml{ .html = try spider.zmd.parse(c.arena, post.body, .{}) },
    }, .{});
}
html
extends "layout"

<main class="ui-reading">
    <BackLink href="/posts" label="All posts" />

    <header class="ui-post-header">
        <h1 class="ui-headline">{ post.title }</h1>
        <div class="ui-author-row">
            <span class="ui-avatar ui-avatar-sm ui-avatar-{ post.tone }" aria-hidden="true">{ post.initials }</span>
            <p class="ui-author-meta">
                <span class="ui-author">{ post.author }</span>
                <span class="ui-when">{ post.written }</span>
            </p>
        </div>
    </header>

    <article class="ui-prose">
        { body }
    </article>

    if (can_edit) {
    <div class="ui-actions ui-actions-ruled">
        <a class="ui-btn ui-btn-outline" href="/posts/{ post.id }/edit">Edit post</a>
        <a class="ui-btn ui-btn-danger-quiet" href="/posts/{ post.id }/delete">Delete post</a>
    </div>
    }
</main>

Hiding the buttons is a courtesy to the reader. What protects the post is the route: someone who types the address of the edit page of a post that is not theirs gets the 403.

The 403 page

A refusal reaches the app's error handler as error.Forbidden. Posts gives it a page of its own, with forbidden_view:

zig
/// Where every error a handler or a middleware returns ends up. The API
/// answers in JSON; a missing post or page gets the 404 page, a post that
/// is someone else's the 403 page; anything else gets Spider's own answer.
fn onError(c: *spider.Ctx, err: anyerror) anyerror!spider.Response {
    if (std.mem.startsWith(u8, c.getPath(), "/api/")) return api_errors(c, err);
    if (err == error.NotFound) {
        return c.view("not-found", .{ .title = "Page not found" }, .{ .status = .not_found });
    }
    return fallback(c, err);
}

const fallback = spider.errorHandler(.{
    .unauthorized_redirect = "/auth/login",
    .forbidden_view = "forbidden",
});

const api_errors = spider.errorHandler(.{ .always_json = true });
html
extends "layout"

<main class="ui-narrow">
    <p class="ui-eyebrow">403</p>
    <h1 class="ui-headline">This post is not yours</h1>
    <p class="ui-lead">Only the account that wrote a post can change or delete it.</p>
    <div class="ui-actions">
        <a class="ui-btn ui-btn-primary" href="/posts">See all posts</a>
    </div>
</main>

Answering 404 instead

A 403 tells the caller that the post exists. When that is already too much (a private document, an invoice), make a refusal look like a missing page:

zig
pub const Posts = spider.policySet(model.Post, .{
    .name = "post",
    .load = load,
    .deny = .not_found,
    .rules = .{
        .update = isAuthor,
        .delete = isAuthor,
    },
});

A rule that is not about a post

spider.policy is for a rule with nothing to load: the function receives the request alone.

zig
fn openToday(c: *spider.Ctx) bool {
    _ = c;
    return false;
}
zig
.get("/new", controller.new, .{ .policy = spider.policy("open_today", openToday) })

In the tests

A policy shows in the access table of the feature by its name, so a route that loses its rule fails zig build test:

zig
test "posts routes: method, path and access" {
    try spider.testing.expectRoutes(routes.build(), &.{
        .{ "GET", "/posts", "public" },
        .{ "GET", "/posts/new", "authenticated" },
        .{ "GET", "/posts/export.csv", "public" },
        .{ "POST", "/posts/create", "authenticated" },
        .{ "GET", "/posts/:id", "public" },
        .{ "GET", "/posts/:id/edit", "policy:post.update" },
        .{ "POST", "/posts/:id/update", "policy:post.update" },
        .{ "GET", "/posts/:id/delete", "policy:post.delete" },
        .{ "POST", "/posts/:id/delete", "policy:post.delete" },
    });
}

And the rule itself is tested by calling the app as two different accounts:

zig
test "only the author changes or deletes a post: someone else gets 403" {
    const app = try spider.testing.start(run);
    var cookie: [1024]u8 = undefined;
    const author = app.with(&.{try signUp(app, &cookie, "Owner Writer", "owner@example.com")});
    const post = try publish(author, "Mine to change", "Hands off.");
    defer std.testing.allocator.free(post);
    var other_cookie: [1024]u8 = undefined;
    const other = app.with(&.{try signUp(app, &other_cookie, "Other Person", "other@example.com")});
    var buf: [64]u8 = undefined;

    for ([_][]const u8{ "/edit", "/delete" }) |page| {
        var res = try other.get(path(&buf, post, page));
        defer res.deinit();
        try res.expectStatus(403);
        try res.expectContains("This post is not yours");
    }

    var update = try other.postForm(path(&buf, post, "/update"), .{ .title = "Taken over", .body = "x" });
    defer update.deinit();
    try update.expectStatus(403);

    var delete = try other.postForm(path(&buf, post, "/delete"), .{});
    defer delete.deinit();
    try delete.expectStatus(403);

    // Nothing changed.
    var still = try app.get(post);
    defer still.deinit();
    try still.expectStatus(200);
    try still.expectContains("Mine to change");
    try still.expectNotContains("Taken over");
}

Testing explains signUp, publish and app.with.