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:

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 — below an exact user name and the :username placeholder, and above * (see Matching model).

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. Such a rule is discarded in full — not merely stripped of its selector — so an Allow rule written that way grants nothing and the MOVE is denied by default. 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.

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