Authorization rules

An AuthorizationRule is an HTML Microdata item that declares which actors can perform which HTTP methods on which resources. An optional selector field narrows the rule to specific elements within a document, giving element-granular access control.

Rules are expressed as structured HTML inside the site itself, not in a separate policy language or configuration file. The server evaluates them during request handling.

Fields

actor

Identifies the subject the rule applies to. Accepted values:

To say "any signed-in user", write users. It is a built-in group, so you do not have to create it, and every authenticated principal belongs to it. authenticated is an alias for the same thing. Neither matches an anonymous request.

Multiple actor values express OR semantics — the rule matches if the request actor matches any of them.

An email address and a group name are matched at the same specificity tier as any other membership token — above * and below an exact user name (see Matching model).

:username as an actor is deprecated

The token :username (and the equivalent ${username} and ${request.auth.username} spellings) can also be written in an actor field, where it matches any signed-in user. It still works, but do not write new rules with it. Use users instead.

Two reasons. First, the name misleads: as an actor it does not match one named user, it matches everyone who is signed in — which is what users says plainly. Second, :-prefixed actor tokens are being repurposed as capture references, so this meaning will go away (dombase issue #1528).

One difference matters while you migrate. :username matches at a higher specificity tier than users does: :username sits above a group name, and users is a group name. So swapping :username for users lowers a rule's tier, and a rule at the same tier that denies will now beat it. Check any rule you change against the conflict-resolution rules.

resource

Path pattern identifying the files the rule covers. Values are glob patterns (e.g. /admin/*). At least one resource is required.

method

One or more HTTP methods the rule applies to: GET, POST, PUT, DELETE, and so on.

The wildcard * matches every method. In OPTIONS capability discovery, a wildcard grant is reported as the concrete methods the server supports — GET, HEAD, PUT, DELETE, POST, MOVE, PATCH for a whole document, or that set without MOVE and PATCH when the rule is selector-scoped (both apply only to whole documents) — never as a literal * in the Allow header.

MOVE is a special case in two respects. A MOVE rule must not carry a selector — see selector below. And a MOVE rule alone does not authorize a move: an element MOVE is authorized by three checks (MOVE on the document, DELETE on the source element, POST on the destination anchor), all of which must pass. See MOVE authorization for the full model.

selector (optional)

A CSS selector that restricts the rule to elements inside the matched resource. When present, the rule applies only to elements satisfying the selector. When omitted, the rule applies to the entire document.

This field is what makes authorization element-granular rather than file-level.

Selectors that match several elements

A request selector can match more than one element — Range: selector=.item on a page with three .item elements matches all three. What the platform does with the extra matches differs between reads and writes, and authorization follows that difference exactly:

The practical consequence for reads: if you deny a fragment, denying it is enough. You do not also have to anticipate every broader selector that happens to match it as well.

For an all-matches read, this decision is made over the selector's whole match set, not page by page. If a paginated read asks for a range of a selector's matches, and any element that selector matches is denied, every page of that selector is refused — including pages that contain none of the denied elements. Denial is deliberately decided by the selector you asked with, so that narrowing the page range is never a way to work around it.

A rule whose method includes MOVE must leave selector empty. An Allow rule written that way is discarded in full — not merely stripped of its selector — so it grants nothing and the MOVE is denied by default. A Deny rule is kept instead, with the selector treated as absent, so it refuses the MOVE across the whole resource: dropping a refusal would let a broader Allow permit what it was written to block. An empty or whitespace-only selector cell is fine. Element-granular control over moves comes from the DELETE and POST rules that a MOVE is also checked against; see MOVE authorization.

action

The outcome when the rule matches:

When more than one rule matches, the result is decided by the conflict-resolution rules below — not by the order you wrote them in.

Templated values

actor, resource, and selector don't have to be static text — each can contain a lookup that the server fills in per request, from the requesting actor's identity and the request itself. This is what lets a rule say "the actor whose name matches this URL segment" without writing a separate rule for every value.

There is one syntax: ${path.to.value}, a dot-path lookup. Write ${request.auth.username} for the current user's name; :username is an older shorthand for the same lookup and still works. A path that resolves to nothing becomes the empty string.

A lookup reads one object:

Object Contents
request method, path, headers (every request header), query_string and a parsed query object when the URL has one, and — only for an authenticated request — auth.sub, auth.username, auth.email, auth.name

Only text, numbers and true/false can be looked up. A path that reaches a list or an object — ${request.auth.roles}, for instance — produces the empty string rather than a rendered list. Match roles with the role: actor form instead.

Liquid is not available in these fields, and {{ … }} is ordinary text. It used to be: a field containing {{ was rendered as a full Liquid template. That was removed because a rule's fields are filled in from the request — headers included — so a client could put {{ … }} in a header and cause a template to run while its own permissions were being decided.

If you have a rule written the old way, it now matches nothing — the braces are compared as literal text. Which way that fails depends on the rule: an allow written that way simply stops granting, but a deny written that way stops denying, which is the direction that matters.

Nothing warns you about this, so check for it yourself. Search your authorization documents for {{ and rewrite those fields using ${…}. The server does not detect such rules and cannot rewrite them for you.

A substituted value in a resource is a literal

resource is a path pattern, so * and the other pattern characters mean something in it. A value substituted into it does not: it is matched as literal text. A rule written /users/${request.auth.username}/* grants each signed-in user the area named after them, and the trailing * is yours — it keeps working. If a principal's name happens to contain *, ?, [ or {, the rule matches the area whose name is exactly that, rather than expanding to other people's.

One case remains open, and it is worth knowing if your principals' names can contain a slash: a substituted value containing / still spans two path segments, so a name like a/b makes the rule reach /users/a/b/…. Making that inert is tracked separately, because a rule that matches nothing is a narrowing on an allow and a widening on a deny.

Every substituted value is treated this way, because ${…} is now the only form. The caveat that used to sit here — that a Liquid-rendered resource could not be protected, since the engine returned one finished string with no way to tell your pattern from the value — went with the engine.

Filling in a rule's lookups costs nothing worth measuring. A ${…} lookup reads one value from the request and copies it in, so unlike a page's template there is no loop to run and no filter chain to apply. A rule cannot exhaust the request's budget, and cannot cause the 503 Service Unavailable a budget failure produces elsewhere. That used to be possible, when these fields accepted full templates; removing them removed the failure with them.

Matching model

A rule matches a request when all of the following hold:

  1. The request actor matches one of the rule's actor values (or *).
  2. The request path matches one of the rule's resource patterns.
  3. The request HTTP method matches one of the rule's method values.
  4. If a selector is present, the request targets at least one element matching that selector.

When all conditions match, the rule's action is applied.

Conflict resolution

A request usually matches more than one rule. The outcome is order-independent — it does not depend on the order the rules appear in a document, or on the order the platform merges them from the path, the host, and the server. Two things decide it:

  1. The most actor-specific rule wins. A rule naming a specific actor outranks one naming a group or role that actor belongs to, which outranks the wildcard *. Only the matching rules at the highest actor specificity are considered; broader ones are set aside. So a specific allow alice beats a blanket deny * — which is what makes the common pattern (deny everyone, then grant a few principals) work as written.

  2. Among equally-specific rules, deny wins. If the top-specificity rules disagree, any deny among them refuses the request; an allow can never override a deny at the same specificity. So allow editors together with deny suspended, for an actor who is both (both group-level), resolves to deny.

The same resolution applies at both granularities. Resource-level rules (no selector) gate the request as a whole; selector-level rules (with a selector) are first narrowed to those whose selector matches the targeted element, then resolved by the same two steps. When the request selector targets several elements at once, each targeted element is resolved separately by these two steps — see Selectors that match several elements for which elements a read and a write each target. When no rule matches at all, the request is denied — unless it is a GET/HEAD and the host's default-GET mode grants the read. This exception does not extend to a Server-Sent Events subscribe request (a GET with Accept: text/event-stream): a long-lived stream with no matching rule is always denied, regardless of the default-GET mode.

Groups and membership

When an actor names a group, the rule applies to every member of that group. A group name sits at the same specificity tier as a verified email (see Conflict resolution), so the allow-list pattern composes: * … Deny alongside editors … Allow grants the group and no one else.

Membership can come from a platform-native Group document (verified-email members), the OIDC roles claim, or the built-in users / authenticated groups — however it arises, it resolves to the group name used here. See Groups for the full reference.

Example: blog admin interface

A blog engine with an administrative UI under /admin/. The following table encodes several authorization rules using HTML Microdata. Note how the * /admin/* GET Deny and admins /admin/* GET Allow rules cooperate: for a request to /admin/ both match, but admins is more actor-specific than *, so the admin is allowed and everyone else is denied — the allow-list pattern from Conflict resolution, order-independent.

<table itemscope itemtype="https://pagelove.org/AuthorizationRule">
  <thead>
    <tr>
      <th>Actor</th>
      <th>Resource</th>
      <th>Method(s)</th>
      <th>Selector</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>

    <!-- Everyone can read everything by default -->
    <tr>
      <td itemprop="actor">*</td>
      <td itemprop="resource">/*</td>
      <td itemprop="method">GET</td>
      <td itemprop="selector"></td>
      <td itemprop="action">Allow</td>
    </tr>

    <!-- Non-admin users cannot access /admin -->
    <tr>
      <td itemprop="actor">*</td>
      <td itemprop="resource">/admin/*</td>
      <td itemprop="method">GET</td>
      <td itemprop="selector"></td>
      <td itemprop="action">Deny</td>
    </tr>

    <!-- Admins can access /admin -->
    <tr>
      <td itemprop="actor">admins</td>
      <td itemprop="resource">/admin/*</td>
      <td itemprop="method">GET</td>
      <td itemprop="selector"></td>
      <td itemprop="action">Allow</td>
    </tr>

    <!-- Admins can create new posts -->
    <tr>
      <td itemprop="actor">admins</td>
      <td itemprop="resource">/admin/posts/*</td>
      <td>
        <ul>
          <li itemprop="method">POST</li>
        </ul>
      </td>
      <td itemprop="selector">li#posts</td>
      <td itemprop="action">Allow</td>
    </tr>

    <!-- Admins can delete existing posts -->
    <tr>
      <td itemprop="actor">admins</td>
      <td itemprop="resource">/admin/posts/*</td>
      <td>
        <ul>
          <li itemprop="method">DELETE</li>
        </ul>
      </td>
      <td itemprop="selector">li#posts li[itemprop="*Post"]</td>
      <td itemprop="action">Allow</td>
    </tr>

    <!-- Admins can update fields within posts -->
    <tr>
      <td itemprop="actor">admins</td>
      <td itemprop="resource">/admin/posts/*</td>
      <td>
        <ul>
          <li itemprop="method">PUT</li>
        </ul>
      </td>
      <td itemprop="selector">li#posts li[itemprop="*Post"] > [itemprop]</td>
      <td itemprop="action">Allow</td>
    </tr>

  </tbody>
</table>

Testable examples

Default deny

When authorization rules are active, any method not explicitly allowed is denied. With only GET allowed, a PUT is rejected:

PUT /authz-deny-test.html
Content-Type: text/html

<!DOCTYPE html>
<html><body><p>Modified</p></body></html>
HTTP/1.1 401

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>401 Unauthorized</title>
</head>
<body itemscope itemtype="https://pagelove.org/1.0/Error">
    <h1>401 Unauthorized</h1>
    <p itemprop="message">Authentication required to access this resource.</p>
    <dl>
        <dt>Resource</dt>
        <dd itemprop="resource">/authz-deny-test.html</dd>
    </dl>
    <p><a href="/-pagelove/oidc/login">Log in</a></p>
</body>
</html>

Method restrictions

Rules that allow GET and POST but not DELETE reject delete requests:

DELETE /authz-method-test.html
Range: selector=li
HTTP/1.1 401

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>401 Unauthorized</title>
</head>
<body itemscope itemtype="https://pagelove.org/1.0/Error">
    <h1>401 Unauthorized</h1>
    <p itemprop="message">Authentication required to access this resource.</p>
    <dl>
        <dt>Resource</dt>
        <dd itemprop="resource">/authz-method-test.html</dd>
    </dl>
    <p><a href="/-pagelove/oidc/login">Log in</a></p>
</body>
</html>

Path restrictions

Rules scoped to /public/* do not cover /admin/*. A GET to /admin/ is denied:

GET /admin/secret.html
HTTP/1.1 401

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>401 Unauthorized</title>
</head>
<body itemscope itemtype="https://pagelove.org/1.0/Error">
    <h1>401 Unauthorized</h1>
    <p itemprop="message">Authentication required to access this resource.</p>
    <dl>
        <dt>Resource</dt>
        <dd itemprop="resource">/admin/secret.html</dd>
    </dl>
    <p><a href="/-pagelove/oidc/login">Log in</a></p>
</body>
</html>

See also