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:
-- 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:
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:
/// 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:
- Nobody signed in: 401. The post is not even loaded. In Posts that becomes a redirect to the sign-in page.
loadgives null: 404.- The rule says no: 403.
- 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:
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:
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, .{}) },
}, .{});
}
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:
/// 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 });
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:
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.
fn openToday(c: *spider.Ctx) bool {
_ = c;
return false;
}
.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:
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:
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.