Ulabase

Build

Guards

Block or redirect a request that fails a rule of yours, such as terms not accepted or a lapsed subscription, before it reaches the data. Evaluated in order.

Guards block or redirect requests that fail rules you write: a user who has not accepted the current terms, a lapsed subscription, a feature that is not in the customer’s plan.

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.

The Guards page: the feature enabled

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:

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 permissions. These variables are available:

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 Rules that should not apply to anonymous callers.

@roles

The caller’s roles, as an array: to scope a rule to staff, say. To test for anonymity use @authenticated.

@team

The document of the user’s currently active team, from the teams collection.

@team.role

The role this user has in that team: owner, member, or whatever your application uses.

@request

Request metadata, including @request.body for the request payload.

A few examples:

not path-prefix('/auth') and not path-prefix('/token') and not path('/users/me') and not equals(@user.latestConsents.tos, '2026-07-01')
method(POST) and not equals(@team.plan, 'pro')
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

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:

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:

# 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 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

  1. Open Guards in the service.

  2. Click Enable next to the title.

  3. The page shows the rules editor.

With Guards enabled and no rules configured, nothing changes: every request proceeds as before.

Add and edit rules

  1. Click Add rule. A new rule opens in the editor.

  2. Fill in the id, the condition and the action, plus the message or the location depending on the action.

  3. 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.

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:

not path-prefix('/auth') and not path-prefix('/token') and not path('/users/me') and <your actual condition>

Those three are the ones a blocked user needs:

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:

{
  "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 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:

{
  "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" },
    "teams":       { "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": <millis>}. 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 Permissions):

{
  "_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:

curl -X PATCH 'https://<service>/users/<userid>' \
  -H 'Authorization: Bearer <token>' \
  -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 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.

{
  "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:

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 Locking users out.

  • 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:

curl -X GET 'https://<service>/token?renew=true' -H 'Authorization: Bearer <token>'

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:

curl -X GET 'https://<service>/token' -u '<userid>:<password>'

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.

# 1. A token. Must succeed: if this is blocked, /token is missing from the exclusions.
curl -X GET 'https://<service>/token' -u '<userid>:<password>'

# 2. Any other request. Must be blocked with your status code and message.
curl -X GET 'https://<service>/<collection>' -H 'Authorization: Bearer <token>'

# 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://<service>/users/<userid>' \
  -H 'Authorization: Bearer <token>' -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://<service>/token' -u '<userid>:<password>'

# 5. The request from step 2 again, with the new token. Must now pass.
curl -X GET 'https://<service>/<collection>' -H 'Authorization: Bearer <token>'

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:

  1. A request comes back with the status code the rule sets, 451 above.

  2. The client shows its acceptance screen.

  3. 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.

  • Permissions: what each role may do, checked before guards.

  • Users: user documents and the JWT claims a service issues.

  • Schemas: the shape of the data your conditions read.

  • Origin allowlist: which websites can call your API.