Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions content/docs/build/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,17 @@ While a request is pending:
for `per_group` — so the inbox and your own UIs render progress without
re-implementing the tally.

**Who can see a request (17.0).** A request carries a snapshot of the record,
its whole decision history and its attachments, so only its participants
read it: the submitter, a current approver (including the holders of a
position the request waits on), and anyone who has already acted or
commented on it. Platform and organization administrators see every request.
Everyone else gets nothing, and being able to read the record does not change
that. Filtering a request list by approver is a filter, not a grant: leaving
it out returns only the requests you take part in, never the whole tenant.
Inside a visible request, the snapshot drops every field the reader cannot
read on the record — and, since 17.6, every field they read only masked.

**Decisions are declared actions (16.0).** The full decision set — approve,
reject, reassign, send-back for revision (`/revise`), request-info, remind,
recall, resubmit — is declared as `type: 'api'` actions on
Expand Down
18 changes: 18 additions & 0 deletions content/docs/configure/api-access.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,24 @@ To revoke a key, run the `revoke_api_key` action on the corresponding
`sys_api_key` record (also available in Setup). Revocation takes
effect immediately on the next request.

### Keys belong to an organization (ObjectStack 17.1)

A key is minted against the caller's **active organization**:
`POST /api/v1/keys` takes no organization parameter, so there is no
cross-organization key, and the caller's membership is re-checked at mint
time. On a single-organization deployment that changes nothing you can see.
Under a walled tenancy posture (`OS_TENANCY_POSTURE`, see
[Environment Variables](/docs/reference/environment-variables#multi-organization-deployments)),
it decides which keys still work:

| Key | `group` | `isolated` |
|---|---|---|
| Minted on 17.1 or later, owner still a member of its organization | Works | Works |
| Owner has since left the key's organization | Refused | Refused |
| Minted before 17.1 — it carries no organization | Works | Refused — re-mint it |

Keys minted before 17.1 are never back-filled with an organization.

## MCP clients & OAuth 2.1 (ObjectStack 13+)

Any OAuth-capable MCP client — Claude.ai custom connectors, Claude
Expand Down
47 changes: 47 additions & 0 deletions content/docs/configure/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,51 @@ support:
- phone number sign-in with SMS OTP (opt-in);
- CLI/browser device flow.

## Who may sign up (ObjectStack 17.3+)

Self-registration is **closed by default**. Who may create their own
account is one setting, **Setup → Authentication → Audience**:

| Audience | Who may sign up |
|---|---|
| `invite_only` (default) | Only an address holding a pending invitation |
| `email_domain` | Addresses on the domains you allow — exact match, case-insensitive; subdomains need their own entry |
| `open` | Anyone |

The audience judges every self-serve path: email/password sign-up, a
first sign-in through a social provider, magic link, OTP and phone. A
refused sign-up answers `403 SELF_REGISTRATION_CLOSED` (or
`403 EMAIL_DOMAIN_NOT_ALLOWED` under `email_domain`). Two rules come with
opening it:

- `email_domain` and `open` both need a **self-registration permission
set** — the set each self-registrant receives (`member_default` is
fine; `admin_full_access` is refused).
- Both turn email verification on: `email_domain` always, `open` unless
the deployment's own configuration turns it off.

The **Allow self-service registration** toggle under *Email and password*
does not override the audience: with the audience at `invite_only`,
sign-up stays closed even while that toggle is on.

Creating people yourself is never gated by the audience — invitations,
**Create User**, bulk import and enterprise SSO work under every setting
(see [Users](/docs/configure/users#add-people)). The very first account on
a fresh install is exempt too.

> **Upgrading from 17.2 or earlier:** an undeclared audience used to mean
> open email/password sign-up. It now means `invite_only`, and nothing
> fails at upgrade — a deployment that relied on open sign-up simply stops
> accepting it. Choose `open` (with a self-registration permission set)
> to keep it.

The audience is a different decision from the **membership policy**
(**Setup → Authentication → Membership**, or `OS_AUTH_MEMBERSHIP_POLICY`
— see
[Environment Variables](/docs/reference/environment-variables#multi-organization-deployments)).
The audience decides *whether* someone may become a user at all; the
membership policy decides *which organization* a new user joins.

## Phone sign-in & SMS (ObjectStack 14.3+)

Enable phone-number authentication via the `auth.plugins.phoneNumber`
Expand Down Expand Up @@ -145,6 +190,8 @@ wants every project to own a completely separate login boundary.
Before production:

- confirm expired tokens return `401`;
- confirm the sign-up audience matches who should be able to create an
account;
- confirm logout revokes the active session;
- confirm password reset revokes other sessions if required by policy;
- confirm callback URLs match the public project domain;
Expand Down
94 changes: 81 additions & 13 deletions content/docs/configure/permissions/field-level-security.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Field-Level Security
seoTitle: "Field-Level Security: Grants and API Enforcement"
description: Hide or lock down individual fields — grant semantics, server-side enforcement, and how FLS behaves in forms, views, and the API.
description: Hide, mask or lock down individual fields — grant semantics, server-side enforcement, and how FLS behaves in forms, views, and the API.
---

Field-level security (FLS) controls the visibility and editability of
Expand Down Expand Up @@ -47,6 +47,55 @@ The two flags produce three states:
> `security-fls-unqualified-key` lint rejects bare keys at compile
> time, because they silently matched nothing.

## Masked fields

Since ObjectStack 17.1 a field can also be **masked**: served with most
of its value replaced, so it stays recognizable without being
disclosed. Masking is declared on the field itself, not in a permission
set:

```ts
// Fields of an object; the object around them is omitted.
mobile: Field.phone({
label: 'Mobile',
maskingRule: 'phone', // served as 138****5678
requiredPermissions: ['view_pii'], // holders of view_pii see the stored value
}),
tax_id: Field.text({
label: 'Tax ID',
maskingRule: { keepHead: 2, keepTail: 2 }, // keep two characters at each end
}),
```

`maskingRule` takes one of five presets — `phone`, `id_card`,
`bank_account`, `email`, `name` — or `{ keepHead, keepTail }`, the
number of characters to keep at each end. The mask is deterministic and
keeps the value's length.

Who sees the stored value:

| Caller | Sees |
|---|---|
| Holds **every** capability in the field's `requiredPermissions` | The stored value |
| Anyone else, when the field declares `requiredPermissions` | The mask |
| Everyone except system context, when the field declares no `requiredPermissions` | The mask |
| A caller from whom a `readable: false` grant hides the field | Nothing — the field stays hidden |

The capability in `requiredPermissions` is granted like any other, by a
permission set. Masking never widens a hidden field: a `readable: false`
grant still removes it entirely.

Masking is one value on every surface — API responses, list views and
forms, CSV and XLSX exports, and the context an AI agent reads all serve
the same mask. Two more rules keep it from leaking:

- **A masked field cannot be queried.** Filtering, sorting, grouping or
aggregating on it is refused, as for a hidden field — see
[Enforcement in the API](#enforcement-in-the-api).
- **The mask never overwrites the value.** A write that sends the masked
placeholder back — a client re-submitting a whole record it read — is
refused with `400 VALIDATION_ERROR` instead of storing `138****5678`.

## How grants combine

FLS uses **default-visible (block-list) semantics**: fields without an
Expand All @@ -56,7 +105,7 @@ Permission sets only constrain fields they explicitly enumerate.
Field grants union **most-permissively** across a user's sets: one
set's `readable: true` out-votes another set's `false`. Until a
subtractive muting layer lands (reserved as ADR-0066 ⑧), a
`{ readable: false }` rule masks the field only as long as **no other
`{ readable: false }` rule hides the field only as long as **no other
set the user holds** declares it `readable: true`. The practical
consequences:

Expand All @@ -66,7 +115,7 @@ consequences:
- Treat a sensitive field on a broadly-granted object as a review
smell.

Declared rules themselves are enforced fail-closed: a masked field is
Declared rules themselves are enforced fail-closed: a hidden field is
stripped on read and a write to a non-editable field throws.

## Enforcement in the API
Expand All @@ -76,7 +125,23 @@ regardless of how the request arrived — REST, ObjectQL, or any other
path. There is no back door through a lower-level API.

**On read** — `find` / `findOne` results have non-readable fields
stripped from every record before the response leaves the engine.
stripped from every record before the response leaves the engine, and
masked fields replaced by their mask.

**On query** — a field the caller cannot read, or reads only masked,
cannot be used to select rows either, because which rows match would
disclose its value one probe at a time. Filtering, sorting, grouping or
aggregating on it is refused with `403 PERMISSION_DENIED`. Since
ObjectStack 17.6 that holds at every query door, not only the data
API's filter and sort:

- a field named as the right-hand side of a comparison, in a filter or
an aggregation;
- analytics queries;
- filters and sorts over the copied values in the activity stream, the
[audit log](/docs/operate/audit-logs) and approval snapshots — refused
unless the filter names one object by equality and the caller can read
every field of it.

**On write** — `insert` / `update` requests are checked **before** the
operation reaches the driver. If the payload contains any field the
Expand Down Expand Up @@ -118,23 +183,26 @@ The generated forms and inline grids hide non-editable fields from the
UI — but that is a **UX layer only**. The server-side check above is
the source of truth, so behavior stays consistent everywhere:

| Surface | Hidden field | Read-only field |
|---|---|---|
| Record forms / inline grids | Not rendered | Rendered without an editable control; a direct write attempt is rejected with 403 |
| List views, related lists, exports | Column value stripped from the response | Value shown normally |
| REST / ObjectQL | Stripped from results | Returned on read; write throws `PERMISSION_DENIED` with `forbiddenFields` |
| MCP / AI agents | Stripped — agents [run as the calling user](/docs/configure/mcp#permission-enforcement) | Same as REST |
| Surface | Hidden field | Masked field | Read-only field |
|---|---|---|---|
| Record forms / inline grids | Not rendered | Value shown masked; writing the mask back is rejected with `400 VALIDATION_ERROR` | Rendered without an editable control; a direct write attempt is rejected with 403 |
| List views, related lists, exports | Column value stripped from the response | Masked value shown, in CSV and XLSX exports too | Value shown normally |
| REST / ObjectQL | Stripped from results | Masked value returned | Returned on read; write throws `PERMISSION_DENIED` with `forbiddenFields` |
| MCP / AI agents | Stripped — agents [run as the calling user](/docs/configure/mcp#permission-enforcement) | Same as REST | Same as REST |
| Filter, sort, group, aggregate | Refused with `403 PERMISSION_DENIED` | Refused with `403 PERMISSION_DENIED` | Allowed |

Because reads are stripped rather than errored, a hidden field simply
looks absent to that user — which is the point.
looks absent to that user — which is the point. Querying on it is the
exception: that answers `403`, as above.

## Verifying and reviewing FLS

- **Per decision, at runtime** — the
[explain engine](/docs/configure/permissions#diagnose--audit) reports
the FLS layer's verdict alongside every other layer, with the
contributing permission set named. Use it when a user reports a
"missing" field.
contributing permission set named, and reports a masked field as
masked rather than hidden. Use it when a user reports a "missing"
field.
- **Per change, at build time** — if your app has opted into the
access-matrix snapshot gate, `os compile` diffs the derived
(permission set × object) capability matrix against a committed
Expand Down
Loading
Loading