= Guards :description: Block or redirect a request when a rule of yours fails, such as terms not accepted or a lapsed subscription, before the request reaches the data. :keywords: guards, block requests, terms acceptance, subscription gate, feature gating, request rules, access rules backend :group: Build :order: 30 A **guard** is a rule the service checks before a request reaches the data. When the rule fails, the guard blocks the request or redirects it. Use it for a user who has not accepted the current terms, a lapsed subscription, or a feature that is not in the customer's plan. What you get: * **The check in one place.** The service applies the rule to every request it covers, so your app does not repeat it on each screen. * **Rules in the order you choose.** Guards are evaluated in order. Permissions answer "may this role touch this resource?". Guards answer "is this user, right now, in a state where the application should serve them at all?". That state lives in your data and changes over time, independently of roles, so a guard is the place for it. image::/assets/docs-images/guards.png[The Guards page: the feature enabled, the warning about locking users out, and the list of rules] == How it works Every request is evaluated against your rules **in order**. The first rule whose condition matches performs its action and the evaluation stops. If no rule matches, the request proceeds exactly as it would without Guards. Each rule has: [cols="1,3"] |=== | Field | Meaning | `id` | A short identifier, unique within the service. | `name` | A description for your own benefit. Not used at runtime. | `condition` | A predicate expression. When it matches, the action is performed. | `action` | `block` or `redirect`. | `status_code` | The response status. Defaults to `403` for `block`, `302` for `redirect`. | `message` | The error message returned by `block`. | `location` | The URL `redirect` sends the client to. Required for `redirect`. | `on_error` | What to do when the condition cannot be evaluated: `allow` (the default) or `deny`. |=== === Guards restrict, they never widen Permissions are checked *before* guards. A request your permissions refuse never reaches the rules, so a guard can only narrow access. It can never grant what a permission refused. The reverse holds too: a request that is unauthenticated *on purpose*, such as sign-up, is allowed by a permission and so reaches the guards. That is what makes a rule like "registration is closed" possible. === The service administrator is never guarded Rules never apply to requests authenticated as the service administrator, which is how the console reaches the service. So Guards are safe to experiment with: a rule that blocks everything still leaves the console working, and you can always go back and fix it. == Writing conditions Conditions use the predicate language of xref:managing-permissions.adoc[permissions]. These variables are available: [cols="1,3"] |=== | Variable | Value | `@user` | The authenticated account. `@user.email`, `@user.latestConsents.tos`, and so on. Sub-documents are addressed with dots. | `@authenticated` | `'true'` when the caller is signed in, `'false'` when they are not. See <>. | `@roles` | The caller's roles, as an array: to scope a rule to `staff`, say. To test for anonymity use `@authenticated`. | `@team` | The user's active team, as `{_id, role}`. It is read at every request, so a switch of team counts from the next one. It has no other fields: the team's name or plan are not in it. | `@team.role` | The role *this user* has in that team: `owner`, `member`, or whatever your application uses. | `@subscription` | The subscription of the active team, when the service sells plans with Stripe: `@subscription.plan`, `@subscription.active`, `@subscription.licensed`. See xref:stripe.adoc[Stripe]. | `@request` | Request metadata, including `@request.body` for the request payload. |=== A few examples: [source] ---- not path-prefix('/auth') and not path-prefix('/token') and not path('/users/me') and not equals(@user.latestConsents.tos, '2026-07-01') ---- [source] ---- method(POST) and not equals(@subscription.plan, 'pro') ---- [source] ---- path-prefix('/admin') and not equals(@team.role, 'owner') ---- TIP: The **Guards** page validates every condition against the server's predicate parser before saving. A condition that does not parse cannot be saved. === Rules that should not apply to anonymous callers [[anonymous-callers]] A request that is unauthenticated on purpose reaches the guards: a permission allowed it, so the rules get their say. That makes "registration is closed" possible, and it is also the easiest mistake to make in a condition. A rule on a user's state runs the same comparison for a caller who has no user, finds it false, and acts. The effects vary: * an anonymous visitor browsing a public page is blocked and shown an acceptance form they cannot complete, because accepting means writing to a user document they do not have; * a webhook from a payment provider is refused, so the customer's payment succeeds and the order it would have updated stays pending, and nothing anywhere reports a problem. Guard the rule with `@authenticated`: [source,text] ---- equals(@authenticated, 'true') and not (equals(@user.latestConsents.tos, '2026-07-01') and equals(@user.latestConsents.pp, '2026-07-01')) ---- You could instead exclude every public path by name, alongside `/auth` and `/token`. It works, but you only notice the list when it is wrong, and it grows each time something else becomes reachable without an account, including endpoints a feature registers on its own. [WARNING] ==== `@roles` will not do this job, however natural it looks. It does resolve to `["$unauthenticated"]` for a caller with no account, but `$` is Undertow's sigil for an exchange attribute, so `'$unauthenticated'` written as a value is read as an attribute of that name and resolves to nothing: [source,text] ---- # BROKEN — true for every request, anonymous ones included not in(value='$unauthenticated', array=@roles) ---- A rule guarded that way applies to everybody, the opposite of what it says, and nothing reports it: the condition parses, saves and validates. Use `equals(@authenticated, 'true')`. ==== === `@user` only sees what is in the token For users authenticating with a JWT, `@user` holds the token's claims only. A condition on `@user.latestConsents` never matches unless `latestConsents` is one of the claims your service issues. Set the claim list with **Settings** on the **Users** page (see xref:managing-users.adoc#jwt-claims[Users]). The Guards page warns you, under the condition, when it names a field that is not in the list. Users authenticating with username and password are not affected: for them `@user` is the full user document. == Turn it on . Open **Guards** in the service. . Click **Enable** next to the title. . The page shows the rules editor. With Guards enabled and no rules configured, nothing changes: every request proceeds as before. == Add and edit rules . Click **Add rule**. A new rule opens in the editor. . Fill in the id, the condition and the action, plus the message or the location depending on the action. . Click **Done** to collapse the rule, then **Save** to send the rules to the service. Use the arrows on each rule to change its position. Order is significant: the first matching rule decides. NOTE: Changes are not sent to the server until you click **Save**. TIP: Configuration changes may take up to 60 seconds to propagate to all service nodes. == Do it from the command line You can keep your guard rules in git and apply them with xref:cli.adoc[`ulabase setup`]. The rules are a JSON array in `guards.json`, each rule with the fields of the table above. The steps install the `guards` feature and write the rules: [source,typescript] ---- import { readFileSync } from 'node:fs'; import { defineSetup, step } from '@ulabase/cli'; // the rules, in the order the service evaluates them const rules = JSON.parse(readFileSync('guards.json', 'utf8')); export default defineSetup('Guards', [ // step 1: install the guards feature, if it is not installed yet step('guards feature installed', { check: ({ admin, srvId }) => admin.isFeatureInstalled(srvId, 'guards'), apply: ({ admin, srvId }) => admin.installFeature(srvId, 'guards'), }), // step 2: save the rules, unless the saved ones are the same as the file step('guard rules configured', { async check({ admin, srvId }) { const c = await admin.getFeatureConfig(srvId, 'guards'); return JSON.stringify(c.rules ?? []) === JSON.stringify(rules); }, async apply({ admin, srvId }) { // read the configuration first, so its other fields stay as they are const current = await admin.getFeatureConfig(srvId, 'guards'); await admin.updateFeatureConfig(srvId, 'guards', { ...current, rules }); }, }), ]); ---- The rules are evaluated in the order of the file. Try a new rule on a development service first: a mistake reaches every node within a minute. == Locking users out [[locking-users-out]] The requests users need to *get back in* are requests like any other. A condition that also matches signing in, accepting the terms or the OAuth callback leaves users with no way through. The application looks broken, and nothing in the logs says "misconfigured rule". Only `/ping`, `/health/db` and CORS preflight are never guarded. Everything else your application depends on, including the endpoints that open the session, is matched like any other path and has to be excluded in the condition itself: [source] ---- not path-prefix('/auth') and not path-prefix('/token') and not path('/users/me') and ---- Those three are the ones a blocked user needs: [cols="1,3"] |=== | Path | Why it must stay reachable | `/auth/*` | Sign-up, email verification, password reset, the OAuth callback. For a rule about consents, also the endpoint that records the acceptance. | `/token`, `/token/cookie` | Signing in, and above all `GET /token?renew=true`: a user who has just accepted still holds the old token, so the request that would unblock them is made *while still blocked*. Guard that path and the acceptance can never take effect. | `/users/me` | How the application learns who the user is and what state they are in. Blocked, the frontend cannot even render the screen that asks them to accept. |=== There is no separate allowlist field: the exclusions are part of the condition, so what you read is what is evaluated. Test a rule on a non-production service before enabling it on the one your users depend on. == When a condition cannot be evaluated If a condition fails to evaluate at runtime (a variable that does not resolve, an unexpected data shape), the rule's `on_error` decides. The default is `allow`: the request goes through and the failure is logged. An application locked out by a broken rule is an incident; a rule that lets requests through is a problem you can see and fix. Set `on_error` to `deny` when the rule is a genuine security control and letting a request through would be worse than blocking a legitimate one. == Example: gating on consents A user who has not accepted the current Terms of Service and Privacy Policy is served nothing except the requests that let them accept. Five pieces, in the order you configure them. === The data model Two fields on the user document: [source,json] ---- { "latestConsents": { "tos": "2026-07-01", "pp": "2026-07-01", "acceptedAt": { "$date": 1754438400000 } }, "consents": [ { "tos": "2026-07-01", "pp": "2026-07-01", "acceptedAt": { "$date": 1754438400000 } } ] } ---- * `latestConsents`: a flat sub-document, overwritten at each acceptance. The guard condition and the JWT claims read this one. * `consents`: an append-only array, one record per acceptance. The history, for the day someone asks what a user agreed to and when. There are two flags because the terms and the privacy policy have versions of their own. The same request accepts both: the client does not choose *what* it accepts, and that keeps this to one permission and one rule. An acceptance that can be split (the privacy policy mandatory, the terms optional) needs a different design, with a permission and a rule per flag. `POST /auth/register` writes neither field, and their absence is exactly what the guard blocks on. There is one way to accept, the `PATCH` of step 2, the same whether the user has just signed up, arrived through OAuth, or has had an account since before the current version. === 1. Validate the format Add a JSON Schema on `/users` (see xref:schemas.adoc[Schemas]) that validates the two fields *when present*. Do _not_ make them required: their absence is the state the guard exists to catch. The schema also declares OAuth fields (`socialAuths`, `profile.avatarUrl`) and team fields (`teams`, `team`) as optional, so documents created via OAuth or after team operations pass validation. On the **Schemas** page, **Create schema** takes the schema `_id` in **its own field** and the schema body in the editor. Through the API it goes in the body like in any other MongoDB document: `POST /schemas` with `{"_id": "userConsentsSchema", ...}`. *Schema id:* `userConsentsSchema` *Schema document:* [source,json] ---- { "title": "User with consents", "description": "Validates consents format when present; allows OAuth and team fields", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "required": ["_id", "password", "roles", "profile"], "properties": { "_id": { "type": "string" }, "_etag": { "type": "object" }, "password": { "type": "string" }, "roles": { "type": "array", "items": { "type": "string" } }, "profile": { "type": "object", "required": ["name", "surname"], "properties": { "name": { "type": "string" }, "surname": { "type": "string" }, "avatarUrl": { "type": "string" } } }, "latestConsents": { "type": "object", "properties": { "tos": { "type": "string" }, "pp": { "type": "string" }, "acceptedAt": { "type": "object", "properties": { "_$date": { "type": "number" } } } }, "required": ["tos", "pp"] }, "consents": { "type": "array", "items": { "type": "object", "properties": { "tos": { "type": "string" }, "pp": { "type": "string" }, "acceptedAt": { "type": "object", "properties": { "_$date": { "type": "number" } } } }, "required": ["tos", "pp"] } }, "socialAuths": { "type": "array" }, "team": { "type": "object" } } } ---- The two `_id` are different things: the one in the form field identifies the *schema*, the one under `properties` describes the `_id` of a *user document*, the email. IMPORTANT: `acceptedAt` is a BSON date, which renders as `{"$date": }`. Inside a schema document the BSON type keys are written with a leading underscore, `_$date`, so that the request parser does not read them as BSON values while the schema is being stored. Declaring it as `{"type": "string"}` rejects every acceptance. IMPORTANT: Declare `_etag` and leave it out of `required`. RESTHeart adds `_etag` to every document it writes, so a schema that forbids extra properties without declaring it rejects RESTHeart's own writes; and it is absent on the very first insert, so requiring it rejects document creation. The same applies to any other field the platform adds. With `additionalProperties` left at its default (permissive) you only need to declare what you constrain. NOTE: The schema is enforced on the *resulting* document, so a `PATCH` carrying only the consents is validated against the whole user document as it will be once written. A `PUT`, which replaces the document, must carry everything in `required`. === 2. Authorize the acceptance The user accepts with a `PATCH /users/{userId}` on their own document. No permission allows that request out of the box, and the guard never sees a request the permissions refused: without a permission of your own the acceptance is a `403` and the user is locked out for good. `restheart-accounts` does install a rule of its own on `/users`, a **veto**: it denies self-service writes to `_id`, `password`, `roles`, `team`, `teams`, `sub`, `socialAuths`, `providerId` and the one-shot token fields. A veto never grants. Every other field, `consents` included, is left to your permissions. Create the permission on the **Permissions** page (see xref:managing-permissions.adoc[Permissions]): [source,json] ---- { "_id": "userCanPatchOwnConsents", "predicate": "path-template('/users/{userId}') and method(PATCH) and (equals(@user._id, ${userId}) or equals(@user.sub, ${userId})) and bson-request-whitelist(consents)", "roles": ["user"], "priority": 1000, "mongo": { "mergeRequest": { "latestConsents": { "tos": "2026-07-01", "pp": "2026-07-01", "acceptedAt": "@now" }, "_$push": { "consents": { "tos": "2026-07-01", "pp": "2026-07-01", "acceptedAt": "@now" } } } } } ---- The request the client sends is then: [source,bash] ---- curl -X PATCH 'https:///users/' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"consents": []}' ---- A few things are doing work here: * `${userId}`, with dollar and braces, is the value the path template captured. The `@user.sub` branch covers users authenticating with a JWT, who carry the account id in `sub`. * `roles: ["user"]` is the role an account carries once verified. Use whatever role your service assigns. * `priority: 1000` puts it ahead of any broader permission of the same role on `/users`: higher numbers are evaluated first, and the first match decides which `mergeRequest` applies. * `bson-request-whitelist(consents)` narrows the permission to that single field: it grants the acceptance and nothing else. It runs *before* `mergeRequest`, so it sees the body as the client sent it. * `mergeRequest` moves the decision of *what* is being accepted to the server. Without it the client states the version and the timestamp itself: it could accept terms it was never shown, or backdate `acceptedAt`. The body above carries `consents` only to satisfy the whitelist; its value is discarded. * The two entries write the two fields of the model. `latestConsents` is a plain field and becomes a `$set`. `_$push` is a MongoDB update operator: written with a leading underscore in the permission document, unescaped to `$push` before the merge, it appends the record to the history array. * `@now` resolves to a BSON date, the same value in both entries. IMPORTANT: Write `latestConsents` as a nested object, without dotted keys. A merged request that sets both `latestConsents` and a path inside it is rejected by MongoDB with `500 ConflictingUpdateOperators: Updating the path 'latestConsents.tos' would create a conflict at 'latestConsents'`. The nested object replaces the sub-document in a single key and cannot conflict. === 3. Expose the flags as claims Add **both** `latestConsents/tos` and `latestConsents/pp` to the JWT claims, with **Settings** on the **Users** page (see xref:managing-users.adoc#jwt-claims[Users]), so the condition also holds for users authenticating with a token. WARNING: If one of the two is missing from the claim list, the `equals` on it is false forever for every user authenticating with a token, including those who have just accepted. The rule then blocks them permanently, and nothing in the condition looks wrong. === 4. Write the rule The rule must exempt the request the user sends to accept: the `PATCH` of step 2. `bson-request-whitelist(consents)` scopes the exemption to exactly that request, so the rule does not open up self-service writes to any other field. [source,json] ---- { "id": "consentsGate", "name": "Block users who have not accepted the current ToS and Privacy Policy", "condition": "not path-prefix('/auth') and not path-prefix('/token') and not path('/users/me') and not (method(PATCH) and path-template('/users/{userId}') and bson-request-whitelist(consents)) and not (equals(@user.latestConsents.tos, '2026-07-01') and equals(@user.latestConsents.pp, '2026-07-01'))", "action": "block", "status_code": 451, "message": "You must accept the current Terms of Service and Privacy Policy", "on_error": "allow" } ---- The same condition, broken up as it reads: [source] ---- not path-prefix('/auth') and not path-prefix('/token') and not path('/users/me') and not (method(PATCH) and path-template('/users/{userId}') and bson-request-whitelist(consents)) and not (equals(@user.latestConsents.tos, '2026-07-01') and equals(@user.latestConsents.pp, '2026-07-01')) ---- * The first three exclusions are the session itself: signing in, renewing the token after the acceptance, and reading the user. Leave any of them out and users are locked out; see <>. * The exemption whitelists `consents`, the same field written the same way as in the permission of step 2. The two predicates see the same body, so a mismatch between them means the acceptance is authorized and then blocked by the rule that exists to let it through. (`bson-request-whitelist` does take several comma-separated and dotted keys, `bson-request-whitelist(consents.tos, consents.pp)`; that form fits a body that names the sub-keys.) * `not (A and B)` blocks when *either* acceptance is missing. `not A and not B` would block only the users who accepted neither. * The rule reads `latestConsents`, never the `consents` array. The array is history; access decisions are made on the flat sub-document, whose keys resolve in a predicate with the dotted form `@user.latestConsents.tos`. === 5. Get the user a token that reflects the acceptance A JWT is a snapshot taken when it is issued. After a user accepts, the token they hold still says they have not, and the rule keeps blocking them. So the acceptance has to be followed by a new token. Which request you make depends on what the client is holding. **Holding a bearer token**, the usual case right after an acceptance: add **`?renew=true`** to get a new one without sending the credentials again: [source,bash] ---- curl -X GET 'https:///token?renew=true' -H 'Authorization: Bearer ' ---- This request is made while the user is still blocked: the token it carries says they have not accepted. That is why `/token` is among the exclusions of the condition. Without it the acceptance can never take effect. **Authenticating with credentials**: `?renew=true` is not needed. Every `GET /token` authenticated with Basic credentials is a fresh issuance built from the user document as it is read at that moment: [source,bash] ---- curl -X GET 'https:///token' -u ':' ---- The renewed token is built from the user document read again from the database, so it carries `latestConsents` as it is at that moment, along with any other change, including the user's roles. NOTE: When the user cannot be read from the service's `users` collection, the token is renewed from its own claims instead: a later expiry, the same data. That happens when the token was issued elsewhere, or by a realm that is not backed by a users collection. Renewal keeps working, with nothing fresher to build from. TIP: A JWT payload is only base64-encoded: any client holding the token can read the claims. Keep the list to the flags the rule compares (`latestConsents/tos`, `latestConsents/pp`), leave the rest of the sub-document out, and leave the `consents` history out: it is an array that grows at every acceptance, and no access decision reads it. Often better: **have the acceptance endpoint return the new token in the same response**. That is what `restheart-accounts` does when a user switches team or activates their account. It saves a round trip, and the user never passes through a state where they are blocked by a decision they have already made. Signing out and back in works too. === Checking it works Five requests, on a test account that has not accepted yet. Run them in order: one that does not answer as described tells you which piece is wrong. [source,bash] ---- # 1. A token. Must succeed: if this is blocked, /token is missing from the exclusions. curl -X GET 'https:///token' -u ':' # 2. Any other request. Must be blocked with your status code and message. curl -X GET 'https:///' -H 'Authorization: Bearer ' # 3. The acceptance. Must return 200 — a 403 means the permission of step 2 is missing # or does not match; a 500 ConflictingUpdateOperators means dotted keys in mergeRequest. curl -X PATCH 'https:///users/' \ -H 'Authorization: Bearer ' -H 'Content-Type: application/json' \ -d '{"consents": []}' # 4. A fresh token. Decode the payload: latestConsents must carry both flags at the # current versions. Only one of them means only one is in the JWT claim list. curl -X GET 'https:///token' -u ':' # 5. The request from step 2 again, with the new token. Must now pass. curl -X GET 'https:///' -H 'Authorization: Bearer ' ---- Then check the user document: `latestConsents` carries the versions and an `acceptedAt` written by the server, and `consents` gains one record per acceptance. TIP: With both flags on the same version, a condition mistakenly written `not A and not B` behaves exactly like the correct `not (A and B)`. To exercise that difference, raise *one* version in the rule and in `mergeRequest` and confirm the user is blocked again. === How the app uses this The application never checks whether the user has accepted. It reacts to being told: . A request comes back with the status code the rule sets, `451` above. . The client shows its acceptance screen. . On acceptance it sends the `PATCH` of step 2, then gets a new token as in step 5. Nothing in the client compares versions. That is what keeps the two sides from drifting: raise the version in the rule and in the permission, and every client starts seeing `451` on the next request, with nothing to rebuild or redeploy. NOTE: `POST /auth/register` *can* carry the fields: with a JSON Schema configured, it copies into the user document the body properties it does not map itself. So a sign-up form could record the acceptance up front. There is no `mergeRequest` on that path, though, so the client would be stating the version and the timestamp itself. Leaving registration alone and letting the same `PATCH` serve every user is both simpler and better attested. == Turn it off **Disable**, next to the title, turns Guards off: requests proceed as if there were no rules. The rules are kept, and **Enable** applies them again. == Best practices * Start with one rule and a narrow condition. Broad conditions are how lockouts happen. * Put the exclusions (`/auth`, `/token`, `/users/me`) at the *front* of the condition, where they are hard to miss when re-reading it. * Order rules from the most specific to the most general: the first match wins. * Prefer `block` with a status code your frontend can act on (`451`, `402`, `403`) over a redirect, unless the client is a browser navigating between pages. * Leave `on_error` at `allow` unless the rule is a security control. == Related pages * xref:managing-permissions.adoc[Permissions]: what each role may do, checked before guards. * xref:managing-users.adoc[Users]: user documents and the JWT claims a service issues. * xref:schemas.adoc[Schemas]: the shape of the data your conditions read. * xref:origin-allowlist.adoc[Origin allowlist]: which websites can call your API.