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:
- A user name — the request principal's OIDC
sub (subject) claim, e.g. alice. The :username placeholder expands to the same value, so actor :username matches the currently-authenticated user.
- A verified email address, e.g.
alice@example.com — matches the principal whose OIDC email claim equals it, only when that email is verified (email_verified is true). An unverified or absent email never matches an email actor.
- A group name, e.g.
editors — matches any principal that belongs to the group (see Groups and membership). The built-in groups users and authenticated match any signed-in principal.
- The wildcard
*, matching all actors, including anonymous requests.
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:
- A read that asks for all the matches is authorized against all of them. A
GET that sends Accept: multipart/mixed (or Accept: application/ld+json) is answered with every match — as a multipart/mixed body, or as a @graph array. Every one of those elements is authorized independently, and the read succeeds only if all of them are permitted. A single element covered by a Deny, or left uncovered when the host does not grant unmatched reads, refuses the whole request — a broad selector cannot be used to sweep up elements that a narrower rule protects.
- A read that asks for one element is authorized against that element. A
GET with any other Accept (an ordinary Accept: text/html, say) is answered with the first match only, so only that element has to be permitted. Nothing else the selector happens to match is returned, so nothing else is checked.
- A write changes only the first match. A
PUT, DELETE, or POST whose selector matches several elements acts on the first match in document order only, so authorization is decided against that one element.
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:
Allow — permit the request
Deny — reject the request
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:
- The request actor matches one of the rule's
actor values (or *).
- The request path matches one of the rule's
resource patterns.
- The request HTTP method matches one of the rule's
method values.
- 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:
-
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.
-
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
Groups
A group is a named set of principals. Name a group in an AuthorizationRule actor, and the rule applies to every member — so you grant a permission once and manage who holds it in one place.
Membership can come from a Group document you store on the host, the identity provider's roles, or the built-in groups. However it arises, it resolves to the group name used in actor, so a rule need not know where the membership came from.
The Group document
The platform-native group is an HTML Microdata item of type https://pagelove.org/Group, stored anywhere on the host. It carries a name and one member per email address. Membership is managed as data — editing the document is the whole operation.
<div itemscope itemtype="https://pagelove.org/Group">
<meta itemprop="name" content="editors">
<meta itemprop="member" content="alice@example.com">
<meta itemprop="member" content="bob@example.com">
</div>
A request whose verified email matches one of the member values is granted the group's name (editors) as an actor.
Fields
| Field |
Cardinality |
Description |
name |
1..1 |
The group name — the value an AuthorizationRule actor names to grant the group's members. A group with a blank name is ignored. |
member |
0..n |
A member's email address, matched exactly against the request principal's verified email. |
Verified email only
A member is matched against the principal's email only when that email is verified — the OIDC email_verified claim must be true. An unverified or absent email is never a member, so it can never inherit a group's grants. This is the same trust gate as a verified-email actor.
Custom membership: subtype Group and override includes()
Membership is decided by the group's includes(email) method. The base Group's implementation is the exact-email match over member values described above — and because it is an ordinary schema method, you can define your own group type (a schema whose parent is https://pagelove.org/Group) and override includes() to implement any membership policy Sessel can express. A subtype group participates in authorization exactly like a base Group — its name grants as an actor — with or without member entries.
For example, a group that includes everyone at an email domain:
<div itemscope itemtype="https://pagelove.org/Schema">
<meta itemprop="type" content="https://example.com/TeamGroup">
<meta itemprop="name" content="TeamGroup">
<meta itemprop="parent" content="https://pagelove.org/Group">
<div itemprop="property" itemscope itemtype="https://pagelove.org/Property">
<meta itemprop="name" content="domain">
<meta itemprop="type" content="https://schema.host/Text">
<meta itemprop="cardinality" content="1..1">
</div>
<li itemprop="property" itemscope itemtype="https://pagelove.org/Method">
<meta itemprop="name" content="includes">
<meta itemprop="returns" content="https://schema.host/Boolean">
<li itemprop="parameter" itemscope itemtype="https://pagelove.org/Parameter">
<meta itemprop="name" content="email">
<meta itemprop="type" content="https://schema.host/Text">
</li>
<div itemprop="implementation" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">email.endsWith(self.domain)</script>
</div>
</li>
</div>
Store the schema under /system/schemas/, then store instances of it like any group:
<div itemscope itemtype="https://example.com/TeamGroup">
<span itemprop="name">example-staff</span>
<meta itemprop="domain" content="@example.com">
</div>
Every request whose verified email ends in @example.com is now a member of example-staff — no member list to maintain. A subtype that does not override includes() inherits the base exact-email behaviour.
Other sources of membership
A principal can also belong to a group without a Group document:
- The OIDC
roles claim — any role carried in the principal's session (asserted by the identity provider) is usable directly as a group name in an actor.
- The built-in groups
users and authenticated — both match any signed-in principal, regardless of roles or Group documents. They differ from *, which also matches anonymous requests.
So actor editors matches whether that membership came from a Group document, an OIDC role, or was named directly.
How a group actor is matched
A group name sits at the same specificity tier as a role or a verified email — below an exact user name, above the wildcard *. The usual allow-list pattern therefore composes: a blanket * … Deny alongside a specific editors … Allow grants the group and no one else. See Conflict resolution for how overlapping rules settle.
<div itemscope itemtype="https://pagelove.org/AuthorizationRule">
<meta itemprop="actor" content="editors">
<meta itemprop="resource" content="/blog/*">
<meta itemprop="method" content="POST">
<meta itemprop="method" content="PUT">
<meta itemprop="method" content="DELETE">
<meta itemprop="action" content="Allow">
</div>
Membership beyond verified email
The built-in Group matches on verified email only. When you need membership keyed on something else — internal user ids, a service principal, a directory lookup — keep that membership in your own documents and expand it with a schema @read resolver on AuthorizationRule's actor property. The Group-based permissions recipe walks through it.
See also