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
3 changes: 2 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_AUTH_URL` | url | `http://localhost:<OS_PORT>` | Public base URL of the auth server. Required behind a proxy or in production. |
| `OS_AUTH_SECRET` | string | auto-generated (dev) | Secret used to sign sessions and cookies. **Required** in production. |
| `OS_AUTH_TWO_FACTOR` | boolean | `false` | Enable the low-level better-auth two-factor plugin. Keep disabled unless your UI handles enrollment, login challenge, and backup-code recovery. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. The very first user can still sign up to bootstrap admin. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. Under the `single` posture the very first user can still sign up to bootstrap admin; under the walled postures no sign-up is ever promoted, so this leaves the deployment dependent on `OS_PLATFORM_OWNER_EMAIL` alone. |
| `OS_AUTH_EMAIL_PASSWORD_ENABLED` | boolean | settings default | Settings env override for `auth.email_password_enabled`. Controls local email/password login. |
| `OS_AUTH_SIGNUP_ENABLED` | boolean | settings default | Settings env override for `auth.signup_enabled`. Takes precedence over UI settings and is preferred over `OS_DISABLE_SIGNUP`. |
| `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` | boolean | settings default | Settings env override for `auth.require_email_verification`. |
Expand All@@ -86,6 +86,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = <active organization>`, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. |
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. |
| `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). |
| `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. |
| `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). |
| `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. |
Expand Down
75 changes: 68 additions & 7 deletions content/docs/deployment/self-hosting.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -402,13 +402,74 @@ decrypt each other's secrets. All replicas must share the same

## First boot: create the admin

On a fresh production database there are no users yet. Open the deployment's
root URL and **sign up — the very first account to register becomes the
bootstrap admin** (this works even with `OS_DISABLE_SIGNUP=true`, which only
blocks sign-ups after that first account exists). Do this immediately after
the first deploy, before sharing the URL; then create your real user accounts
and lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` /
[SSO](/docs/permissions/sso) as policy dictates.
How the first administrator is created depends on the deployment's
[tenancy posture](/docs/deployment/tenancy-modes), and the two paths are not
interchangeable.

**`single` (the default) — the first account wins.** On a fresh production
database there are no users yet. Open the deployment's root URL and **sign up —
the very first account to register becomes the bootstrap admin** (this works
even with `OS_DISABLE_SIGNUP=true`, which only blocks sign-ups after that first
account exists). Do this immediately after the first deploy, before sharing the
URL; then create your real user accounts and lock sign-up down via
`OS_AUTH_SIGNUP_ENABLED` / [SSO](/docs/permissions/sso) as policy dictates.

**Walled postures (`group` / `isolated`) — you name the administrators in
configuration.** First-registrant promotion is removed there: with
self-registration reachable, whoever posts to the sign-up endpoint first would
otherwise receive cross-tenant access. No grant row is written on those
postures, ever. Declare the administrators **before first boot** instead:

```bash
# one address, or a comma-separated list
OS_PLATFORM_OWNER_EMAIL=ops@example.com,backup-admin@example.com
```

Then have each of those people register with exactly that address and **verify
their email**. Standing is recomputed on every request from the configured list
and the account's own stored record, so it appears the moment verification
completes — there is nothing to grant and nothing to click.

The rules below are enforced by the runtime, not advisory:

- **Declare more than one address.** A single mailbox is a single point of
human failure; losing it leaves the deployment with no administrator and no
in-product recovery.
- **Verified only.** An account that holds a declared address but whose email
is not verified is **not** an administrator. A record that predates the
verification column, or that arrived through an import without it, reads
unverified rather than verified.
- **Case and spacing do not matter.** Entries are trimmed and lower-cased on
both sides of the comparison, duplicates collapse, and a trailing separator
or a blank entry is ignored.
- **One bad entry refuses the whole variable.** If any entry is not an email
address, the deployment gets **zero** configured administrators — not the
remaining good ones. Dropping just the bad entry would leave a *narrower*
administrator set than you declared with nothing anywhere to notice, which is
the more dangerous failure. The runtime names the offending entry in the log;
fix or remove it, then reload.
- **Empty is fail-closed, and a walled boot refuses it.** Unset or blank under
a walled posture **aborts startup**, naming the variable, rather than
silently reverting to promoting the first registrant. A value that is set but
*refused* for a bad entry gets past that startup check — it is not blank — so
the process starts and then has no administrators. Confirm from the boot log,
not from the fact that the process came up.
- **Revocation is a configuration change plus a reload.** No endpoint, UI
action or API call adds or removes a platform administrator; that surface
deliberately does not exist. Edit the variable and roll the process — the
next resolution reads the new value.
- **You cannot quietly remove the last one through the user record, either.**
Changing the last remaining administrator's email address, or clearing their
verified flag, is refused, and the refusal points at the configuration as the
remedy — the same guard that refuses deleting or banning the last
administrator.

On a walled boot the runtime logs the resolved list: per declared address,
whether an account exists, whether it is verified, and which account holds
standing. That line is the fastest check that the configuration and the real
accounts agree. The same answer is available read-only to Setup, discovery and
health surfaces; see
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets).

Note the production server seeds **no** dev credentials — the
`admin@objectos.ai` / `admin123` account you may know from `os dev` exists only
Expand Down
34 changes: 24 additions & 10 deletions content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,8 +110,14 @@ implementation detail:
D1) is a separate always-first AND conjunct, not an OR-mergeable policy.
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1), which derives
only from an **unscoped `admin_full_access` grant** — a *scoped* grant, or
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1). That posture
is derived at one site from **two anchors**, either of which is sufficient:
the deployment's **configured administrator list** — an address declared in
`OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and
**email-verified** `sys_user` row — and an **unscoped `admin_full_access`
grant** row. See
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets)
for both. A *scoped* grant, or
piecemeal platform capabilities (`studio.access`, `manage_users`, …), grant
Studio/admin *functions* but never widen the tenant data boundary. A tenant
`organization_admin` never crosses it (invariant I1).
Expand DownExpand Up@@ -331,7 +337,10 @@ a row outside its window simply stops resolving — in `resolveAuthzContext`,
the explain engine, sharing-rule position expansion, and (transitively) the
delegated-admin gate's held-scope resolution. No background cleanup job is
involved (ADR-0049); the clock is checked on every resolution. An expired
unscoped `admin_full_access` grant no longer derives `platform_admin`.
unscoped `admin_full_access` grant no longer derives `platform_admin` — a
statement about the *grant* anchor. The configured-administrator anchor is not
a stored grant row at all, so it carries no validity window and nothing here
dates it; it is revoked by changing the configuration.

The explain engine reports an expired-but-present row as a dedicated
contributor state ("held until 2026-08-01 — expired"), so "why did access
Expand DownExpand Up@@ -381,7 +390,10 @@ place, by the same discipline: **resolution-time filtering, fail-closed**, in
- A deactivated **permission set** contributes nothing: not its name, not its
`system_permissions`, not its `tab_permissions`. As with an expired grant, a
deactivated unscoped `admin_full_access` no longer derives `platform_admin`
— the flag is applied *before* the posture is derived, not after.
— the flag is applied *before* the posture is derived, not after. A
**configured** administrator is judged elsewhere: that anchor reads the
deployment's configuration and the caller's own `sys_user` row, never the
catalogue row, so the flag does not decide their standing.
- A deactivated **position** stops carrying its permission sets, and its name
stops appearing in `positions`, so a permission set that merely shares the
position's name cannot resolve through it either.
Expand All@@ -407,12 +419,14 @@ away, so a row that predates the column keeps granting. The same predicate
guard's simulation — a guard that modelled "deactivated" differently from the
resolver would permit exactly the write it exists to refuse.

**Deactivating the break-glass set is refused.** `admin_full_access` is what
makes the environment's platform admins, so switching it off would un-make all
of them in one write — and re-activating it needs the permission just lost.
That write is judged like deleting or renaming the row (ADR-0024 D5.2): it is
refused while it would leave the environment with no administrator who can
sign in. Re-activation is never refused.
**Deactivating the break-glass set is refused.** Switching `admin_full_access`
off un-makes every platform admin who holds it *through a grant row*, in one
write — and re-activating it needs the permission just lost. That write is
judged like deleting or renaming the row (ADR-0024 D5.2): it is refused while
it would leave the environment with no administrator who can sign in. The
enumeration behind that judgement counts **both** anchors, so an environment
whose administrators are configured rather than granted is not empty, and the
write is permitted there. Re-activation is never refused.

Deactivation is an incident-response control, so what it does **not** touch is
deliberate. Administration surfaces keep listing and editing a deactivated
Expand Down
35 changes: 35 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -153,6 +153,41 @@ Auto-seeded on boot by `plugin-security`
| `member_default` | Per-org | Standard end-user CRUD; **writes owner-scoped** via `positions: ['org_member']`-domained RLS | The additive baseline — applies to every authenticated request *in addition to* explicit grants (ADR-0090 D5) |
| `viewer_readonly` | Per-org | Read access only | Auditors / read-only stakeholders |

**Who holds `admin_full_access` is not (only) a grant row.** `PLATFORM_ADMIN`
standing is derived at a single site from **two anchors**, and either one is
sufficient:

- **Configuration — `OS_PLATFORM_OWNER_EMAIL`.** The deployment declares its
administrators as one email address, or a comma-separated list of them. A
caller holds standing when their **own stored `sys_user` row** carries a
declared address *and* that row reads **email-verified**; an unverified
account holding a declared address confers nothing, so registering the
operator's address first gains an attacker no standing. Both sides are
compared trimmed and lower-cased, so `Ada@Example.com` and `ada@example.com`
are one administrator, not two half-matches. Nothing is stored and nothing is
granted — the answer is recomputed from configuration on every resolution.
- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set`
row with `organization_id = NULL`, the original anchor. It still confers
standing, and it is **deprecated**: a deployment resolving through it logs a
pointer, once per process, at the configuration line that re-anchors it.

Which anchor a fresh deployment gets depends on its
[tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first
human account to register is still promoted and its grant row written. Under
the walled postures (`group` / `isolated`) **no grant row is ever written** —
first-registrant promotion is removed there, because on a deployment with
self-registration reachable, whoever signs up first would otherwise receive
cross-tenant access. Administrators come from configuration alone. The operator
procedure, including what an unusable value does, is on
[Self-hosting → First boot](/docs/deployment/self-hosting#first-boot-create-the-admin).

The resolved list is **readable, never writable**: a `platformAdmin` service
answers the configured addresses and, per address, whether an account exists,
whether it is verified, and which account holds standing — for Setup, discovery
and health surfaces. There is deliberately **no runtime endpoint that changes
who a platform administrator is**; revocation is a configuration change plus a
process reload.

## Assigning permission sets

Grants are plain data rows:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
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
3 changes: 2 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_AUTH_URL` | url | `http://localhost:<OS_PORT>` | Public base URL of the auth server. Required behind a proxy or in production. |
| `OS_AUTH_SECRET` | string | auto-generated (dev) | Secret used to sign sessions and cookies. **Required** in production. |
| `OS_AUTH_TWO_FACTOR` | boolean | `false` | Enable the low-level better-auth two-factor plugin. Keep disabled unless your UI handles enrollment, login challenge, and backup-code recovery. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. The very first user can still sign up to bootstrap admin. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. Under the `single` posture the very first user can still sign up to bootstrap admin; under the walled postures no sign-up is ever promoted, so this leaves the deployment dependent on `OS_PLATFORM_OWNER_EMAIL` alone. |
| `OS_AUTH_EMAIL_PASSWORD_ENABLED` | boolean | settings default | Settings env override for `auth.email_password_enabled`. Controls local email/password login. |
| `OS_AUTH_SIGNUP_ENABLED` | boolean | settings default | Settings env override for `auth.signup_enabled`. Takes precedence over UI settings and is preferred over `OS_DISABLE_SIGNUP`. |
| `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` | boolean | settings default | Settings env override for `auth.require_email_verification`. |
Expand All@@ -86,6 +86,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = <active organization>`, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. |
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. |
| `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). |
| `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. |
| `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). |
| `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. |
Expand Down
75 changes: 68 additions & 7 deletions content/docs/deployment/self-hosting.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -402,13 +402,74 @@ decrypt each other's secrets. All replicas must share the same

## First boot: create the admin

On a fresh production database there are no users yet. Open the deployment's
root URL and **sign up — the very first account to register becomes the
bootstrap admin** (this works even with `OS_DISABLE_SIGNUP=true`, which only
blocks sign-ups after that first account exists). Do this immediately after
the first deploy, before sharing the URL; then create your real user accounts
and lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` /
[SSO](/docs/permissions/sso) as policy dictates.
How the first administrator is created depends on the deployment's
[tenancy posture](/docs/deployment/tenancy-modes), and the two paths are not
interchangeable.

**`single` (the default) — the first account wins.** On a fresh production
database there are no users yet. Open the deployment's root URL and **sign up —
the very first account to register becomes the bootstrap admin** (this works
even with `OS_DISABLE_SIGNUP=true`, which only blocks sign-ups after that first
account exists). Do this immediately after the first deploy, before sharing the
URL; then create your real user accounts and lock sign-up down via
`OS_AUTH_SIGNUP_ENABLED` / [SSO](/docs/permissions/sso) as policy dictates.

**Walled postures (`group` / `isolated`) — you name the administrators in
configuration.** First-registrant promotion is removed there: with
self-registration reachable, whoever posts to the sign-up endpoint first would
otherwise receive cross-tenant access. No grant row is written on those
postures, ever. Declare the administrators **before first boot** instead:

```bash
# one address, or a comma-separated list
OS_PLATFORM_OWNER_EMAIL=ops@example.com,backup-admin@example.com
```

Then have each of those people register with exactly that address and **verify
their email**. Standing is recomputed on every request from the configured list
and the account's own stored record, so it appears the moment verification
completes — there is nothing to grant and nothing to click.

The rules below are enforced by the runtime, not advisory:

- **Declare more than one address.** A single mailbox is a single point of
human failure; losing it leaves the deployment with no administrator and no
in-product recovery.
- **Verified only.** An account that holds a declared address but whose email
is not verified is **not** an administrator. A record that predates the
verification column, or that arrived through an import without it, reads
unverified rather than verified.
- **Case and spacing do not matter.** Entries are trimmed and lower-cased on
both sides of the comparison, duplicates collapse, and a trailing separator
or a blank entry is ignored.
- **One bad entry refuses the whole variable.** If any entry is not an email
address, the deployment gets **zero** configured administrators — not the
remaining good ones. Dropping just the bad entry would leave a *narrower*
administrator set than you declared with nothing anywhere to notice, which is
the more dangerous failure. The runtime names the offending entry in the log;
fix or remove it, then reload.
- **Empty is fail-closed, and a walled boot refuses it.** Unset or blank under
a walled posture **aborts startup**, naming the variable, rather than
silently reverting to promoting the first registrant. A value that is set but
*refused* for a bad entry gets past that startup check — it is not blank — so
the process starts and then has no administrators. Confirm from the boot log,
not from the fact that the process came up.
- **Revocation is a configuration change plus a reload.** No endpoint, UI
action or API call adds or removes a platform administrator; that surface
deliberately does not exist. Edit the variable and roll the process — the
next resolution reads the new value.
- **You cannot quietly remove the last one through the user record, either.**
Changing the last remaining administrator's email address, or clearing their
verified flag, is refused, and the refusal points at the configuration as the
remedy — the same guard that refuses deleting or banning the last
administrator.

On a walled boot the runtime logs the resolved list: per declared address,
whether an account exists, whether it is verified, and which account holds
standing. That line is the fastest check that the configuration and the real
accounts agree. The same answer is available read-only to Setup, discovery and
health surfaces; see
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets).

Note the production server seeds **no** dev credentials — the
`admin@objectos.ai` / `admin123` account you may know from `os dev` exists only
Expand Down
34 changes: 24 additions & 10 deletions content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,8 +110,14 @@ implementation detail:
D1) is a separate always-first AND conjunct, not an OR-mergeable policy.
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1), which derives
only from an **unscoped `admin_full_access` grant** — a *scoped* grant, or
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1). That posture
is derived at one site from **two anchors**, either of which is sufficient:
the deployment's **configured administrator list** — an address declared in
`OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and
**email-verified** `sys_user` row — and an **unscoped `admin_full_access`
grant** row. See
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets)
for both. A *scoped* grant, or
piecemeal platform capabilities (`studio.access`, `manage_users`, …), grant
Studio/admin *functions* but never widen the tenant data boundary. A tenant
`organization_admin` never crosses it (invariant I1).
Expand DownExpand Up@@ -331,7 +337,10 @@ a row outside its window simply stops resolving — in `resolveAuthzContext`,
the explain engine, sharing-rule position expansion, and (transitively) the
delegated-admin gate's held-scope resolution. No background cleanup job is
involved (ADR-0049); the clock is checked on every resolution. An expired
unscoped `admin_full_access` grant no longer derives `platform_admin`.
unscoped `admin_full_access` grant no longer derives `platform_admin` — a
statement about the *grant* anchor. The configured-administrator anchor is not
a stored grant row at all, so it carries no validity window and nothing here
dates it; it is revoked by changing the configuration.

The explain engine reports an expired-but-present row as a dedicated
contributor state ("held until 2026-08-01 — expired"), so "why did access
Expand DownExpand Up@@ -381,7 +390,10 @@ place, by the same discipline: **resolution-time filtering, fail-closed**, in
- A deactivated **permission set** contributes nothing: not its name, not its
`system_permissions`, not its `tab_permissions`. As with an expired grant, a
deactivated unscoped `admin_full_access` no longer derives `platform_admin`
— the flag is applied *before* the posture is derived, not after.
— the flag is applied *before* the posture is derived, not after. A
**configured** administrator is judged elsewhere: that anchor reads the
deployment's configuration and the caller's own `sys_user` row, never the
catalogue row, so the flag does not decide their standing.
- A deactivated **position** stops carrying its permission sets, and its name
stops appearing in `positions`, so a permission set that merely shares the
position's name cannot resolve through it either.
Expand All@@ -407,12 +419,14 @@ away, so a row that predates the column keeps granting. The same predicate
guard's simulation — a guard that modelled "deactivated" differently from the
resolver would permit exactly the write it exists to refuse.

**Deactivating the break-glass set is refused.** `admin_full_access` is what
makes the environment's platform admins, so switching it off would un-make all
of them in one write — and re-activating it needs the permission just lost.
That write is judged like deleting or renaming the row (ADR-0024 D5.2): it is
refused while it would leave the environment with no administrator who can
sign in. Re-activation is never refused.
**Deactivating the break-glass set is refused.** Switching `admin_full_access`
off un-makes every platform admin who holds it *through a grant row*, in one
write — and re-activating it needs the permission just lost. That write is
judged like deleting or renaming the row (ADR-0024 D5.2): it is refused while
it would leave the environment with no administrator who can sign in. The
enumeration behind that judgement counts **both** anchors, so an environment
whose administrators are configured rather than granted is not empty, and the
write is permitted there. Re-activation is never refused.

Deactivation is an incident-response control, so what it does **not** touch is
deliberate. Administration surfaces keep listing and editing a deactivated
Expand Down
35 changes: 35 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -153,6 +153,41 @@ Auto-seeded on boot by `plugin-security`
| `member_default` | Per-org | Standard end-user CRUD; **writes owner-scoped** via `positions: ['org_member']`-domained RLS | The additive baseline — applies to every authenticated request *in addition to* explicit grants (ADR-0090 D5) |
| `viewer_readonly` | Per-org | Read access only | Auditors / read-only stakeholders |

**Who holds `admin_full_access` is not (only) a grant row.** `PLATFORM_ADMIN`
standing is derived at a single site from **two anchors**, and either one is
sufficient:

- **Configuration — `OS_PLATFORM_OWNER_EMAIL`.** The deployment declares its
administrators as one email address, or a comma-separated list of them. A
caller holds standing when their **own stored `sys_user` row** carries a
declared address *and* that row reads **email-verified**; an unverified
account holding a declared address confers nothing, so registering the
operator's address first gains an attacker no standing. Both sides are
compared trimmed and lower-cased, so `Ada@Example.com` and `ada@example.com`
are one administrator, not two half-matches. Nothing is stored and nothing is
granted — the answer is recomputed from configuration on every resolution.
- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set`
row with `organization_id = NULL`, the original anchor. It still confers
standing, and it is **deprecated**: a deployment resolving through it logs a
pointer, once per process, at the configuration line that re-anchors it.

Which anchor a fresh deployment gets depends on its
[tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first
human account to register is still promoted and its grant row written. Under
the walled postures (`group` / `isolated`) **no grant row is ever written** —
first-registrant promotion is removed there, because on a deployment with
self-registration reachable, whoever signs up first would otherwise receive
cross-tenant access. Administrators come from configuration alone. The operator
procedure, including what an unusable value does, is on
[Self-hosting → First boot](/docs/deployment/self-hosting#first-boot-create-the-admin).

The resolved list is **readable, never writable**: a `platformAdmin` service
answers the configured addresses and, per address, whether an account exists,
whether it is verified, and which account holds standing — for Setup, discovery
and health surfaces. There is deliberately **no runtime endpoint that changes
who a platform administrator is**; revocation is a configuration change plus a
process reload.

## Assigning permission sets

Grants are plain data rows:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
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
3 changes: 2 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_AUTH_URL` | url | `http://localhost:<OS_PORT>` | Public base URL of the auth server. Required behind a proxy or in production. |
| `OS_AUTH_SECRET` | string | auto-generated (dev) | Secret used to sign sessions and cookies. **Required** in production. |
| `OS_AUTH_TWO_FACTOR` | boolean | `false` | Enable the low-level better-auth two-factor plugin. Keep disabled unless your UI handles enrollment, login challenge, and backup-code recovery. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. The very first user can still sign up to bootstrap admin. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. Under the `single` posture the very first user can still sign up to bootstrap admin; under the walled postures no sign-up is ever promoted, so this leaves the deployment dependent on `OS_PLATFORM_OWNER_EMAIL` alone. |
| `OS_AUTH_EMAIL_PASSWORD_ENABLED` | boolean | settings default | Settings env override for `auth.email_password_enabled`. Controls local email/password login. |
| `OS_AUTH_SIGNUP_ENABLED` | boolean | settings default | Settings env override for `auth.signup_enabled`. Takes precedence over UI settings and is preferred over `OS_DISABLE_SIGNUP`. |
| `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` | boolean | settings default | Settings env override for `auth.require_email_verification`. |
Expand All@@ -86,6 +86,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = <active organization>`, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. |
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. |
| `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). |
| `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. |
| `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). |
| `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. |
Expand Down
75 changes: 68 additions & 7 deletions content/docs/deployment/self-hosting.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -402,13 +402,74 @@ decrypt each other's secrets. All replicas must share the same

## First boot: create the admin

On a fresh production database there are no users yet. Open the deployment's
root URL and **sign up — the very first account to register becomes the
bootstrap admin** (this works even with `OS_DISABLE_SIGNUP=true`, which only
blocks sign-ups after that first account exists). Do this immediately after
the first deploy, before sharing the URL; then create your real user accounts
and lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` /
[SSO](/docs/permissions/sso) as policy dictates.
How the first administrator is created depends on the deployment's
[tenancy posture](/docs/deployment/tenancy-modes), and the two paths are not
interchangeable.

**`single` (the default) — the first account wins.** On a fresh production
database there are no users yet. Open the deployment's root URL and **sign up —
the very first account to register becomes the bootstrap admin** (this works
even with `OS_DISABLE_SIGNUP=true`, which only blocks sign-ups after that first
account exists). Do this immediately after the first deploy, before sharing the
URL; then create your real user accounts and lock sign-up down via
`OS_AUTH_SIGNUP_ENABLED` / [SSO](/docs/permissions/sso) as policy dictates.

**Walled postures (`group` / `isolated`) — you name the administrators in
configuration.** First-registrant promotion is removed there: with
self-registration reachable, whoever posts to the sign-up endpoint first would
otherwise receive cross-tenant access. No grant row is written on those
postures, ever. Declare the administrators **before first boot** instead:

```bash
# one address, or a comma-separated list
OS_PLATFORM_OWNER_EMAIL=ops@example.com,backup-admin@example.com
```

Then have each of those people register with exactly that address and **verify
their email**. Standing is recomputed on every request from the configured list
and the account's own stored record, so it appears the moment verification
completes — there is nothing to grant and nothing to click.

The rules below are enforced by the runtime, not advisory:

- **Declare more than one address.** A single mailbox is a single point of
human failure; losing it leaves the deployment with no administrator and no
in-product recovery.
- **Verified only.** An account that holds a declared address but whose email
is not verified is **not** an administrator. A record that predates the
verification column, or that arrived through an import without it, reads
unverified rather than verified.
- **Case and spacing do not matter.** Entries are trimmed and lower-cased on
both sides of the comparison, duplicates collapse, and a trailing separator
or a blank entry is ignored.
- **One bad entry refuses the whole variable.** If any entry is not an email
address, the deployment gets **zero** configured administrators — not the
remaining good ones. Dropping just the bad entry would leave a *narrower*
administrator set than you declared with nothing anywhere to notice, which is
the more dangerous failure. The runtime names the offending entry in the log;
fix or remove it, then reload.
- **Empty is fail-closed, and a walled boot refuses it.** Unset or blank under
a walled posture **aborts startup**, naming the variable, rather than
silently reverting to promoting the first registrant. A value that is set but
*refused* for a bad entry gets past that startup check — it is not blank — so
the process starts and then has no administrators. Confirm from the boot log,
not from the fact that the process came up.
- **Revocation is a configuration change plus a reload.** No endpoint, UI
action or API call adds or removes a platform administrator; that surface
deliberately does not exist. Edit the variable and roll the process — the
next resolution reads the new value.
- **You cannot quietly remove the last one through the user record, either.**
Changing the last remaining administrator's email address, or clearing their
verified flag, is refused, and the refusal points at the configuration as the
remedy — the same guard that refuses deleting or banning the last
administrator.

On a walled boot the runtime logs the resolved list: per declared address,
whether an account exists, whether it is verified, and which account holds
standing. That line is the fastest check that the configuration and the real
accounts agree. The same answer is available read-only to Setup, discovery and
health surfaces; see
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets).

Note the production server seeds **no** dev credentials — the
`admin@objectos.ai` / `admin123` account you may know from `os dev` exists only
Expand Down
34 changes: 24 additions & 10 deletions content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,8 +110,14 @@ implementation detail:
D1) is a separate always-first AND conjunct, not an OR-mergeable policy.
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1), which derives
only from an **unscoped `admin_full_access` grant** — a *scoped* grant, or
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1). That posture
is derived at one site from **two anchors**, either of which is sufficient:
the deployment's **configured administrator list** — an address declared in
`OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and
**email-verified** `sys_user` row — and an **unscoped `admin_full_access`
grant** row. See
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets)
for both. A *scoped* grant, or
piecemeal platform capabilities (`studio.access`, `manage_users`, …), grant
Studio/admin *functions* but never widen the tenant data boundary. A tenant
`organization_admin` never crosses it (invariant I1).
Expand DownExpand Up@@ -331,7 +337,10 @@ a row outside its window simply stops resolving — in `resolveAuthzContext`,
the explain engine, sharing-rule position expansion, and (transitively) the
delegated-admin gate's held-scope resolution. No background cleanup job is
involved (ADR-0049); the clock is checked on every resolution. An expired
unscoped `admin_full_access` grant no longer derives `platform_admin`.
unscoped `admin_full_access` grant no longer derives `platform_admin` — a
statement about the *grant* anchor. The configured-administrator anchor is not
a stored grant row at all, so it carries no validity window and nothing here
dates it; it is revoked by changing the configuration.

The explain engine reports an expired-but-present row as a dedicated
contributor state ("held until 2026-08-01 — expired"), so "why did access
Expand DownExpand Up@@ -381,7 +390,10 @@ place, by the same discipline: **resolution-time filtering, fail-closed**, in
- A deactivated **permission set** contributes nothing: not its name, not its
`system_permissions`, not its `tab_permissions`. As with an expired grant, a
deactivated unscoped `admin_full_access` no longer derives `platform_admin`
— the flag is applied *before* the posture is derived, not after.
— the flag is applied *before* the posture is derived, not after. A
**configured** administrator is judged elsewhere: that anchor reads the
deployment's configuration and the caller's own `sys_user` row, never the
catalogue row, so the flag does not decide their standing.
- A deactivated **position** stops carrying its permission sets, and its name
stops appearing in `positions`, so a permission set that merely shares the
position's name cannot resolve through it either.
Expand All@@ -407,12 +419,14 @@ away, so a row that predates the column keeps granting. The same predicate
guard's simulation — a guard that modelled "deactivated" differently from the
resolver would permit exactly the write it exists to refuse.

**Deactivating the break-glass set is refused.** `admin_full_access` is what
makes the environment's platform admins, so switching it off would un-make all
of them in one write — and re-activating it needs the permission just lost.
That write is judged like deleting or renaming the row (ADR-0024 D5.2): it is
refused while it would leave the environment with no administrator who can
sign in. Re-activation is never refused.
**Deactivating the break-glass set is refused.** Switching `admin_full_access`
off un-makes every platform admin who holds it *through a grant row*, in one
write — and re-activating it needs the permission just lost. That write is
judged like deleting or renaming the row (ADR-0024 D5.2): it is refused while
it would leave the environment with no administrator who can sign in. The
enumeration behind that judgement counts **both** anchors, so an environment
whose administrators are configured rather than granted is not empty, and the
write is permitted there. Re-activation is never refused.

Deactivation is an incident-response control, so what it does **not** touch is
deliberate. Administration surfaces keep listing and editing a deactivated
Expand Down
35 changes: 35 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -153,6 +153,41 @@ Auto-seeded on boot by `plugin-security`
| `member_default` | Per-org | Standard end-user CRUD; **writes owner-scoped** via `positions: ['org_member']`-domained RLS | The additive baseline — applies to every authenticated request *in addition to* explicit grants (ADR-0090 D5) |
| `viewer_readonly` | Per-org | Read access only | Auditors / read-only stakeholders |

**Who holds `admin_full_access` is not (only) a grant row.** `PLATFORM_ADMIN`
standing is derived at a single site from **two anchors**, and either one is
sufficient:

- **Configuration — `OS_PLATFORM_OWNER_EMAIL`.** The deployment declares its
administrators as one email address, or a comma-separated list of them. A
caller holds standing when their **own stored `sys_user` row** carries a
declared address *and* that row reads **email-verified**; an unverified
account holding a declared address confers nothing, so registering the
operator's address first gains an attacker no standing. Both sides are
compared trimmed and lower-cased, so `Ada@Example.com` and `ada@example.com`
are one administrator, not two half-matches. Nothing is stored and nothing is
granted — the answer is recomputed from configuration on every resolution.
- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set`
row with `organization_id = NULL`, the original anchor. It still confers
standing, and it is **deprecated**: a deployment resolving through it logs a
pointer, once per process, at the configuration line that re-anchors it.

Which anchor a fresh deployment gets depends on its
[tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first
human account to register is still promoted and its grant row written. Under
the walled postures (`group` / `isolated`) **no grant row is ever written** —
first-registrant promotion is removed there, because on a deployment with
self-registration reachable, whoever signs up first would otherwise receive
cross-tenant access. Administrators come from configuration alone. The operator
procedure, including what an unusable value does, is on
[Self-hosting → First boot](/docs/deployment/self-hosting#first-boot-create-the-admin).

The resolved list is **readable, never writable**: a `platformAdmin` service
answers the configured addresses and, per address, whether an account exists,
whether it is verified, and which account holds standing — for Setup, discovery
and health surfaces. There is deliberately **no runtime endpoint that changes
who a platform administrator is**; revocation is a configuration change plus a
process reload.

## Assigning permission sets

Grants are plain data rows:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
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
3 changes: 2 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_AUTH_URL` | url | `http://localhost:<OS_PORT>` | Public base URL of the auth server. Required behind a proxy or in production. |
| `OS_AUTH_SECRET` | string | auto-generated (dev) | Secret used to sign sessions and cookies. **Required** in production. |
| `OS_AUTH_TWO_FACTOR` | boolean | `false` | Enable the low-level better-auth two-factor plugin. Keep disabled unless your UI handles enrollment, login challenge, and backup-code recovery. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. The very first user can still sign up to bootstrap admin. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. Under the `single` posture the very first user can still sign up to bootstrap admin; under the walled postures no sign-up is ever promoted, so this leaves the deployment dependent on `OS_PLATFORM_OWNER_EMAIL` alone. |
| `OS_AUTH_EMAIL_PASSWORD_ENABLED` | boolean | settings default | Settings env override for `auth.email_password_enabled`. Controls local email/password login. |
| `OS_AUTH_SIGNUP_ENABLED` | boolean | settings default | Settings env override for `auth.signup_enabled`. Takes precedence over UI settings and is preferred over `OS_DISABLE_SIGNUP`. |
| `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` | boolean | settings default | Settings env override for `auth.require_email_verification`. |
Expand All@@ -86,6 +86,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = <active organization>`, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. |
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. |
| `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). |
| `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. |
| `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). |
| `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. |
Expand Down
75 changes: 68 additions & 7 deletions content/docs/deployment/self-hosting.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -402,13 +402,74 @@ decrypt each other's secrets. All replicas must share the same

## First boot: create the admin

On a fresh production database there are no users yet. Open the deployment's
root URL and **sign up — the very first account to register becomes the
bootstrap admin** (this works even with `OS_DISABLE_SIGNUP=true`, which only
blocks sign-ups after that first account exists). Do this immediately after
the first deploy, before sharing the URL; then create your real user accounts
and lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` /
[SSO](/docs/permissions/sso) as policy dictates.
How the first administrator is created depends on the deployment's
[tenancy posture](/docs/deployment/tenancy-modes), and the two paths are not
interchangeable.

**`single` (the default) — the first account wins.** On a fresh production
database there are no users yet. Open the deployment's root URL and **sign up —
the very first account to register becomes the bootstrap admin** (this works
even with `OS_DISABLE_SIGNUP=true`, which only blocks sign-ups after that first
account exists). Do this immediately after the first deploy, before sharing the
URL; then create your real user accounts and lock sign-up down via
`OS_AUTH_SIGNUP_ENABLED` / [SSO](/docs/permissions/sso) as policy dictates.

**Walled postures (`group` / `isolated`) — you name the administrators in
configuration.** First-registrant promotion is removed there: with
self-registration reachable, whoever posts to the sign-up endpoint first would
otherwise receive cross-tenant access. No grant row is written on those
postures, ever. Declare the administrators **before first boot** instead:

```bash
# one address, or a comma-separated list
OS_PLATFORM_OWNER_EMAIL=ops@example.com,backup-admin@example.com
```

Then have each of those people register with exactly that address and **verify
their email**. Standing is recomputed on every request from the configured list
and the account's own stored record, so it appears the moment verification
completes — there is nothing to grant and nothing to click.

The rules below are enforced by the runtime, not advisory:

- **Declare more than one address.** A single mailbox is a single point of
human failure; losing it leaves the deployment with no administrator and no
in-product recovery.
- **Verified only.** An account that holds a declared address but whose email
is not verified is **not** an administrator. A record that predates the
verification column, or that arrived through an import without it, reads
unverified rather than verified.
- **Case and spacing do not matter.** Entries are trimmed and lower-cased on
both sides of the comparison, duplicates collapse, and a trailing separator
or a blank entry is ignored.
- **One bad entry refuses the whole variable.** If any entry is not an email
address, the deployment gets **zero** configured administrators — not the
remaining good ones. Dropping just the bad entry would leave a *narrower*
administrator set than you declared with nothing anywhere to notice, which is
the more dangerous failure. The runtime names the offending entry in the log;
fix or remove it, then reload.
- **Empty is fail-closed, and a walled boot refuses it.** Unset or blank under
a walled posture **aborts startup**, naming the variable, rather than
silently reverting to promoting the first registrant. A value that is set but
*refused* for a bad entry gets past that startup check — it is not blank — so
the process starts and then has no administrators. Confirm from the boot log,
not from the fact that the process came up.
- **Revocation is a configuration change plus a reload.** No endpoint, UI
action or API call adds or removes a platform administrator; that surface
deliberately does not exist. Edit the variable and roll the process — the
next resolution reads the new value.
- **You cannot quietly remove the last one through the user record, either.**
Changing the last remaining administrator's email address, or clearing their
verified flag, is refused, and the refusal points at the configuration as the
remedy — the same guard that refuses deleting or banning the last
administrator.

On a walled boot the runtime logs the resolved list: per declared address,
whether an account exists, whether it is verified, and which account holds
standing. That line is the fastest check that the configuration and the real
accounts agree. The same answer is available read-only to Setup, discovery and
health surfaces; see
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets).

Note the production server seeds **no** dev credentials — the
`admin@objectos.ai` / `admin123` account you may know from `os dev` exists only
Expand Down
34 changes: 24 additions & 10 deletions content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,8 +110,14 @@ implementation detail:
D1) is a separate always-first AND conjunct, not an OR-mergeable policy.
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1), which derives
only from an **unscoped `admin_full_access` grant** — a *scoped* grant, or
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1). That posture
is derived at one site from **two anchors**, either of which is sufficient:
the deployment's **configured administrator list** — an address declared in
`OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and
**email-verified** `sys_user` row — and an **unscoped `admin_full_access`
grant** row. See
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets)
for both. A *scoped* grant, or
piecemeal platform capabilities (`studio.access`, `manage_users`, …), grant
Studio/admin *functions* but never widen the tenant data boundary. A tenant
`organization_admin` never crosses it (invariant I1).
Expand DownExpand Up@@ -331,7 +337,10 @@ a row outside its window simply stops resolving — in `resolveAuthzContext`,
the explain engine, sharing-rule position expansion, and (transitively) the
delegated-admin gate's held-scope resolution. No background cleanup job is
involved (ADR-0049); the clock is checked on every resolution. An expired
unscoped `admin_full_access` grant no longer derives `platform_admin`.
unscoped `admin_full_access` grant no longer derives `platform_admin` — a
statement about the *grant* anchor. The configured-administrator anchor is not
a stored grant row at all, so it carries no validity window and nothing here
dates it; it is revoked by changing the configuration.

The explain engine reports an expired-but-present row as a dedicated
contributor state ("held until 2026-08-01 — expired"), so "why did access
Expand DownExpand Up@@ -381,7 +390,10 @@ place, by the same discipline: **resolution-time filtering, fail-closed**, in
- A deactivated **permission set** contributes nothing: not its name, not its
`system_permissions`, not its `tab_permissions`. As with an expired grant, a
deactivated unscoped `admin_full_access` no longer derives `platform_admin`
— the flag is applied *before* the posture is derived, not after.
— the flag is applied *before* the posture is derived, not after. A
**configured** administrator is judged elsewhere: that anchor reads the
deployment's configuration and the caller's own `sys_user` row, never the
catalogue row, so the flag does not decide their standing.
- A deactivated **position** stops carrying its permission sets, and its name
stops appearing in `positions`, so a permission set that merely shares the
position's name cannot resolve through it either.
Expand All@@ -407,12 +419,14 @@ away, so a row that predates the column keeps granting. The same predicate
guard's simulation — a guard that modelled "deactivated" differently from the
resolver would permit exactly the write it exists to refuse.

**Deactivating the break-glass set is refused.** `admin_full_access` is what
makes the environment's platform admins, so switching it off would un-make all
of them in one write — and re-activating it needs the permission just lost.
That write is judged like deleting or renaming the row (ADR-0024 D5.2): it is
refused while it would leave the environment with no administrator who can
sign in. Re-activation is never refused.
**Deactivating the break-glass set is refused.** Switching `admin_full_access`
off un-makes every platform admin who holds it *through a grant row*, in one
write — and re-activating it needs the permission just lost. That write is
judged like deleting or renaming the row (ADR-0024 D5.2): it is refused while
it would leave the environment with no administrator who can sign in. The
enumeration behind that judgement counts **both** anchors, so an environment
whose administrators are configured rather than granted is not empty, and the
write is permitted there. Re-activation is never refused.

Deactivation is an incident-response control, so what it does **not** touch is
deliberate. Administration surfaces keep listing and editing a deactivated
Expand Down
35 changes: 35 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -153,6 +153,41 @@ Auto-seeded on boot by `plugin-security`
| `member_default` | Per-org | Standard end-user CRUD; **writes owner-scoped** via `positions: ['org_member']`-domained RLS | The additive baseline — applies to every authenticated request *in addition to* explicit grants (ADR-0090 D5) |
| `viewer_readonly` | Per-org | Read access only | Auditors / read-only stakeholders |

**Who holds `admin_full_access` is not (only) a grant row.** `PLATFORM_ADMIN`
standing is derived at a single site from **two anchors**, and either one is
sufficient:

- **Configuration — `OS_PLATFORM_OWNER_EMAIL`.** The deployment declares its
administrators as one email address, or a comma-separated list of them. A
caller holds standing when their **own stored `sys_user` row** carries a
declared address *and* that row reads **email-verified**; an unverified
account holding a declared address confers nothing, so registering the
operator's address first gains an attacker no standing. Both sides are
compared trimmed and lower-cased, so `Ada@Example.com` and `ada@example.com`
are one administrator, not two half-matches. Nothing is stored and nothing is
granted — the answer is recomputed from configuration on every resolution.
- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set`
row with `organization_id = NULL`, the original anchor. It still confers
standing, and it is **deprecated**: a deployment resolving through it logs a
pointer, once per process, at the configuration line that re-anchors it.

Which anchor a fresh deployment gets depends on its
[tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first
human account to register is still promoted and its grant row written. Under
the walled postures (`group` / `isolated`) **no grant row is ever written** —
first-registrant promotion is removed there, because on a deployment with
self-registration reachable, whoever signs up first would otherwise receive
cross-tenant access. Administrators come from configuration alone. The operator
procedure, including what an unusable value does, is on
[Self-hosting → First boot](/docs/deployment/self-hosting#first-boot-create-the-admin).

The resolved list is **readable, never writable**: a `platformAdmin` service
answers the configured addresses and, per address, whether an account exists,
whether it is verified, and which account holds standing — for Setup, discovery
and health surfaces. There is deliberately **no runtime endpoint that changes
who a platform administrator is**; revocation is a configuration change plus a
process reload.

## Assigning permission sets

Grants are plain data rows:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
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
3 changes: 2 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_AUTH_URL` | url | `http://localhost:<OS_PORT>` | Public base URL of the auth server. Required behind a proxy or in production. |
| `OS_AUTH_SECRET` | string | auto-generated (dev) | Secret used to sign sessions and cookies. **Required** in production. |
| `OS_AUTH_TWO_FACTOR` | boolean | `false` | Enable the low-level better-auth two-factor plugin. Keep disabled unless your UI handles enrollment, login challenge, and backup-code recovery. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. The very first user can still sign up to bootstrap admin. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. Under the `single` posture the very first user can still sign up to bootstrap admin; under the walled postures no sign-up is ever promoted, so this leaves the deployment dependent on `OS_PLATFORM_OWNER_EMAIL` alone. |
| `OS_AUTH_EMAIL_PASSWORD_ENABLED` | boolean | settings default | Settings env override for `auth.email_password_enabled`. Controls local email/password login. |
| `OS_AUTH_SIGNUP_ENABLED` | boolean | settings default | Settings env override for `auth.signup_enabled`. Takes precedence over UI settings and is preferred over `OS_DISABLE_SIGNUP`. |
| `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` | boolean | settings default | Settings env override for `auth.require_email_verification`. |
Expand All@@ -86,6 +86,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = <active organization>`, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. |
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. |
| `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). |
| `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. |
| `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). |
| `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. |
Expand Down
75 changes: 68 additions & 7 deletions content/docs/deployment/self-hosting.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -402,13 +402,74 @@ decrypt each other's secrets. All replicas must share the same

## First boot: create the admin

On a fresh production database there are no users yet. Open the deployment's
root URL and **sign up — the very first account to register becomes the
bootstrap admin** (this works even with `OS_DISABLE_SIGNUP=true`, which only
blocks sign-ups after that first account exists). Do this immediately after
the first deploy, before sharing the URL; then create your real user accounts
and lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` /
[SSO](/docs/permissions/sso) as policy dictates.
How the first administrator is created depends on the deployment's
[tenancy posture](/docs/deployment/tenancy-modes), and the two paths are not
interchangeable.

**`single` (the default) — the first account wins.** On a fresh production
database there are no users yet. Open the deployment's root URL and **sign up —
the very first account to register becomes the bootstrap admin** (this works
even with `OS_DISABLE_SIGNUP=true`, which only blocks sign-ups after that first
account exists). Do this immediately after the first deploy, before sharing the
URL; then create your real user accounts and lock sign-up down via
`OS_AUTH_SIGNUP_ENABLED` / [SSO](/docs/permissions/sso) as policy dictates.

**Walled postures (`group` / `isolated`) — you name the administrators in
configuration.** First-registrant promotion is removed there: with
self-registration reachable, whoever posts to the sign-up endpoint first would
otherwise receive cross-tenant access. No grant row is written on those
postures, ever. Declare the administrators **before first boot** instead:

```bash
# one address, or a comma-separated list
OS_PLATFORM_OWNER_EMAIL=ops@example.com,backup-admin@example.com
```

Then have each of those people register with exactly that address and **verify
their email**. Standing is recomputed on every request from the configured list
and the account's own stored record, so it appears the moment verification
completes — there is nothing to grant and nothing to click.

The rules below are enforced by the runtime, not advisory:

- **Declare more than one address.** A single mailbox is a single point of
human failure; losing it leaves the deployment with no administrator and no
in-product recovery.
- **Verified only.** An account that holds a declared address but whose email
is not verified is **not** an administrator. A record that predates the
verification column, or that arrived through an import without it, reads
unverified rather than verified.
- **Case and spacing do not matter.** Entries are trimmed and lower-cased on
both sides of the comparison, duplicates collapse, and a trailing separator
or a blank entry is ignored.
- **One bad entry refuses the whole variable.** If any entry is not an email
address, the deployment gets **zero** configured administrators — not the
remaining good ones. Dropping just the bad entry would leave a *narrower*
administrator set than you declared with nothing anywhere to notice, which is
the more dangerous failure. The runtime names the offending entry in the log;
fix or remove it, then reload.
- **Empty is fail-closed, and a walled boot refuses it.** Unset or blank under
a walled posture **aborts startup**, naming the variable, rather than
silently reverting to promoting the first registrant. A value that is set but
*refused* for a bad entry gets past that startup check — it is not blank — so
the process starts and then has no administrators. Confirm from the boot log,
not from the fact that the process came up.
- **Revocation is a configuration change plus a reload.** No endpoint, UI
action or API call adds or removes a platform administrator; that surface
deliberately does not exist. Edit the variable and roll the process — the
next resolution reads the new value.
- **You cannot quietly remove the last one through the user record, either.**
Changing the last remaining administrator's email address, or clearing their
verified flag, is refused, and the refusal points at the configuration as the
remedy — the same guard that refuses deleting or banning the last
administrator.

On a walled boot the runtime logs the resolved list: per declared address,
whether an account exists, whether it is verified, and which account holds
standing. That line is the fastest check that the configuration and the real
accounts agree. The same answer is available read-only to Setup, discovery and
health surfaces; see
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets).

Note the production server seeds **no** dev credentials — the
`admin@objectos.ai` / `admin123` account you may know from `os dev` exists only
Expand Down
34 changes: 24 additions & 10 deletions content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,8 +110,14 @@ implementation detail:
D1) is a separate always-first AND conjunct, not an OR-mergeable policy.
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1), which derives
only from an **unscoped `admin_full_access` grant** — a *scoped* grant, or
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1). That posture
is derived at one site from **two anchors**, either of which is sufficient:
the deployment's **configured administrator list** — an address declared in
`OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and
**email-verified** `sys_user` row — and an **unscoped `admin_full_access`
grant** row. See
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets)
for both. A *scoped* grant, or
piecemeal platform capabilities (`studio.access`, `manage_users`, …), grant
Studio/admin *functions* but never widen the tenant data boundary. A tenant
`organization_admin` never crosses it (invariant I1).
Expand DownExpand Up@@ -331,7 +337,10 @@ a row outside its window simply stops resolving — in `resolveAuthzContext`,
the explain engine, sharing-rule position expansion, and (transitively) the
delegated-admin gate's held-scope resolution. No background cleanup job is
involved (ADR-0049); the clock is checked on every resolution. An expired
unscoped `admin_full_access` grant no longer derives `platform_admin`.
unscoped `admin_full_access` grant no longer derives `platform_admin` — a
statement about the *grant* anchor. The configured-administrator anchor is not
a stored grant row at all, so it carries no validity window and nothing here
dates it; it is revoked by changing the configuration.

The explain engine reports an expired-but-present row as a dedicated
contributor state ("held until 2026-08-01 — expired"), so "why did access
Expand DownExpand Up@@ -381,7 +390,10 @@ place, by the same discipline: **resolution-time filtering, fail-closed**, in
- A deactivated **permission set** contributes nothing: not its name, not its
`system_permissions`, not its `tab_permissions`. As with an expired grant, a
deactivated unscoped `admin_full_access` no longer derives `platform_admin`
— the flag is applied *before* the posture is derived, not after.
— the flag is applied *before* the posture is derived, not after. A
**configured** administrator is judged elsewhere: that anchor reads the
deployment's configuration and the caller's own `sys_user` row, never the
catalogue row, so the flag does not decide their standing.
- A deactivated **position** stops carrying its permission sets, and its name
stops appearing in `positions`, so a permission set that merely shares the
position's name cannot resolve through it either.
Expand All@@ -407,12 +419,14 @@ away, so a row that predates the column keeps granting. The same predicate
guard's simulation — a guard that modelled "deactivated" differently from the
resolver would permit exactly the write it exists to refuse.

**Deactivating the break-glass set is refused.** `admin_full_access` is what
makes the environment's platform admins, so switching it off would un-make all
of them in one write — and re-activating it needs the permission just lost.
That write is judged like deleting or renaming the row (ADR-0024 D5.2): it is
refused while it would leave the environment with no administrator who can
sign in. Re-activation is never refused.
**Deactivating the break-glass set is refused.** Switching `admin_full_access`
off un-makes every platform admin who holds it *through a grant row*, in one
write — and re-activating it needs the permission just lost. That write is
judged like deleting or renaming the row (ADR-0024 D5.2): it is refused while
it would leave the environment with no administrator who can sign in. The
enumeration behind that judgement counts **both** anchors, so an environment
whose administrators are configured rather than granted is not empty, and the
write is permitted there. Re-activation is never refused.

Deactivation is an incident-response control, so what it does **not** touch is
deliberate. Administration surfaces keep listing and editing a deactivated
Expand Down
35 changes: 35 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -153,6 +153,41 @@ Auto-seeded on boot by `plugin-security`
| `member_default` | Per-org | Standard end-user CRUD; **writes owner-scoped** via `positions: ['org_member']`-domained RLS | The additive baseline — applies to every authenticated request *in addition to* explicit grants (ADR-0090 D5) |
| `viewer_readonly` | Per-org | Read access only | Auditors / read-only stakeholders |

**Who holds `admin_full_access` is not (only) a grant row.** `PLATFORM_ADMIN`
standing is derived at a single site from **two anchors**, and either one is
sufficient:

- **Configuration — `OS_PLATFORM_OWNER_EMAIL`.** The deployment declares its
administrators as one email address, or a comma-separated list of them. A
caller holds standing when their **own stored `sys_user` row** carries a
declared address *and* that row reads **email-verified**; an unverified
account holding a declared address confers nothing, so registering the
operator's address first gains an attacker no standing. Both sides are
compared trimmed and lower-cased, so `Ada@Example.com` and `ada@example.com`
are one administrator, not two half-matches. Nothing is stored and nothing is
granted — the answer is recomputed from configuration on every resolution.
- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set`
row with `organization_id = NULL`, the original anchor. It still confers
standing, and it is **deprecated**: a deployment resolving through it logs a
pointer, once per process, at the configuration line that re-anchors it.

Which anchor a fresh deployment gets depends on its
[tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first
human account to register is still promoted and its grant row written. Under
the walled postures (`group` / `isolated`) **no grant row is ever written** —
first-registrant promotion is removed there, because on a deployment with
self-registration reachable, whoever signs up first would otherwise receive
cross-tenant access. Administrators come from configuration alone. The operator
procedure, including what an unusable value does, is on
[Self-hosting → First boot](/docs/deployment/self-hosting#first-boot-create-the-admin).

The resolved list is **readable, never writable**: a `platformAdmin` service
answers the configured addresses and, per address, whether an account exists,
whether it is verified, and which account holds standing — for Setup, discovery
and health surfaces. There is deliberately **no runtime endpoint that changes
who a platform administrator is**; revocation is a configuration change plus a
process reload.

## Assigning permission sets

Grants are plain data rows:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
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
3 changes: 2 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_AUTH_URL` | url | `http://localhost:<OS_PORT>` | Public base URL of the auth server. Required behind a proxy or in production. |
| `OS_AUTH_SECRET` | string | auto-generated (dev) | Secret used to sign sessions and cookies. **Required** in production. |
| `OS_AUTH_TWO_FACTOR` | boolean | `false` | Enable the low-level better-auth two-factor plugin. Keep disabled unless your UI handles enrollment, login challenge, and backup-code recovery. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. The very first user can still sign up to bootstrap admin. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. Under the `single` posture the very first user can still sign up to bootstrap admin; under the walled postures no sign-up is ever promoted, so this leaves the deployment dependent on `OS_PLATFORM_OWNER_EMAIL` alone. |
| `OS_AUTH_EMAIL_PASSWORD_ENABLED` | boolean | settings default | Settings env override for `auth.email_password_enabled`. Controls local email/password login. |
| `OS_AUTH_SIGNUP_ENABLED` | boolean | settings default | Settings env override for `auth.signup_enabled`. Takes precedence over UI settings and is preferred over `OS_DISABLE_SIGNUP`. |
| `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` | boolean | settings default | Settings env override for `auth.require_email_verification`. |
Expand All@@ -86,6 +86,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = <active organization>`, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. |
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. |
| `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). |
| `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. |
| `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). |
| `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. |
Expand Down
75 changes: 68 additions & 7 deletions content/docs/deployment/self-hosting.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -402,13 +402,74 @@ decrypt each other's secrets. All replicas must share the same

## First boot: create the admin

On a fresh production database there are no users yet. Open the deployment's
root URL and **sign up — the very first account to register becomes the
bootstrap admin** (this works even with `OS_DISABLE_SIGNUP=true`, which only
blocks sign-ups after that first account exists). Do this immediately after
the first deploy, before sharing the URL; then create your real user accounts
and lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` /
[SSO](/docs/permissions/sso) as policy dictates.
How the first administrator is created depends on the deployment's
[tenancy posture](/docs/deployment/tenancy-modes), and the two paths are not
interchangeable.

**`single` (the default) — the first account wins.** On a fresh production
database there are no users yet. Open the deployment's root URL and **sign up —
the very first account to register becomes the bootstrap admin** (this works
even with `OS_DISABLE_SIGNUP=true`, which only blocks sign-ups after that first
account exists). Do this immediately after the first deploy, before sharing the
URL; then create your real user accounts and lock sign-up down via
`OS_AUTH_SIGNUP_ENABLED` / [SSO](/docs/permissions/sso) as policy dictates.

**Walled postures (`group` / `isolated`) — you name the administrators in
configuration.** First-registrant promotion is removed there: with
self-registration reachable, whoever posts to the sign-up endpoint first would
otherwise receive cross-tenant access. No grant row is written on those
postures, ever. Declare the administrators **before first boot** instead:

```bash
# one address, or a comma-separated list
OS_PLATFORM_OWNER_EMAIL=ops@example.com,backup-admin@example.com
```

Then have each of those people register with exactly that address and **verify
their email**. Standing is recomputed on every request from the configured list
and the account's own stored record, so it appears the moment verification
completes — there is nothing to grant and nothing to click.

The rules below are enforced by the runtime, not advisory:

- **Declare more than one address.** A single mailbox is a single point of
human failure; losing it leaves the deployment with no administrator and no
in-product recovery.
- **Verified only.** An account that holds a declared address but whose email
is not verified is **not** an administrator. A record that predates the
verification column, or that arrived through an import without it, reads
unverified rather than verified.
- **Case and spacing do not matter.** Entries are trimmed and lower-cased on
both sides of the comparison, duplicates collapse, and a trailing separator
or a blank entry is ignored.
- **One bad entry refuses the whole variable.** If any entry is not an email
address, the deployment gets **zero** configured administrators — not the
remaining good ones. Dropping just the bad entry would leave a *narrower*
administrator set than you declared with nothing anywhere to notice, which is
the more dangerous failure. The runtime names the offending entry in the log;
fix or remove it, then reload.
- **Empty is fail-closed, and a walled boot refuses it.** Unset or blank under
a walled posture **aborts startup**, naming the variable, rather than
silently reverting to promoting the first registrant. A value that is set but
*refused* for a bad entry gets past that startup check — it is not blank — so
the process starts and then has no administrators. Confirm from the boot log,
not from the fact that the process came up.
- **Revocation is a configuration change plus a reload.** No endpoint, UI
action or API call adds or removes a platform administrator; that surface
deliberately does not exist. Edit the variable and roll the process — the
next resolution reads the new value.
- **You cannot quietly remove the last one through the user record, either.**
Changing the last remaining administrator's email address, or clearing their
verified flag, is refused, and the refusal points at the configuration as the
remedy — the same guard that refuses deleting or banning the last
administrator.

On a walled boot the runtime logs the resolved list: per declared address,
whether an account exists, whether it is verified, and which account holds
standing. That line is the fastest check that the configuration and the real
accounts agree. The same answer is available read-only to Setup, discovery and
health surfaces; see
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets).

Note the production server seeds **no** dev credentials — the
`admin@objectos.ai` / `admin123` account you may know from `os dev` exists only
Expand Down
34 changes: 24 additions & 10 deletions content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,8 +110,14 @@ implementation detail:
D1) is a separate always-first AND conjunct, not an OR-mergeable policy.
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1), which derives
only from an **unscoped `admin_full_access` grant** — a *scoped* grant, or
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1). That posture
is derived at one site from **two anchors**, either of which is sufficient:
the deployment's **configured administrator list** — an address declared in
`OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and
**email-verified** `sys_user` row — and an **unscoped `admin_full_access`
grant** row. See
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets)
for both. A *scoped* grant, or
piecemeal platform capabilities (`studio.access`, `manage_users`, …), grant
Studio/admin *functions* but never widen the tenant data boundary. A tenant
`organization_admin` never crosses it (invariant I1).
Expand DownExpand Up@@ -331,7 +337,10 @@ a row outside its window simply stops resolving — in `resolveAuthzContext`,
the explain engine, sharing-rule position expansion, and (transitively) the
delegated-admin gate's held-scope resolution. No background cleanup job is
involved (ADR-0049); the clock is checked on every resolution. An expired
unscoped `admin_full_access` grant no longer derives `platform_admin`.
unscoped `admin_full_access` grant no longer derives `platform_admin` — a
statement about the *grant* anchor. The configured-administrator anchor is not
a stored grant row at all, so it carries no validity window and nothing here
dates it; it is revoked by changing the configuration.

The explain engine reports an expired-but-present row as a dedicated
contributor state ("held until 2026-08-01 — expired"), so "why did access
Expand DownExpand Up@@ -381,7 +390,10 @@ place, by the same discipline: **resolution-time filtering, fail-closed**, in
- A deactivated **permission set** contributes nothing: not its name, not its
`system_permissions`, not its `tab_permissions`. As with an expired grant, a
deactivated unscoped `admin_full_access` no longer derives `platform_admin`
— the flag is applied *before* the posture is derived, not after.
— the flag is applied *before* the posture is derived, not after. A
**configured** administrator is judged elsewhere: that anchor reads the
deployment's configuration and the caller's own `sys_user` row, never the
catalogue row, so the flag does not decide their standing.
- A deactivated **position** stops carrying its permission sets, and its name
stops appearing in `positions`, so a permission set that merely shares the
position's name cannot resolve through it either.
Expand All@@ -407,12 +419,14 @@ away, so a row that predates the column keeps granting. The same predicate
guard's simulation — a guard that modelled "deactivated" differently from the
resolver would permit exactly the write it exists to refuse.

**Deactivating the break-glass set is refused.** `admin_full_access` is what
makes the environment's platform admins, so switching it off would un-make all
of them in one write — and re-activating it needs the permission just lost.
That write is judged like deleting or renaming the row (ADR-0024 D5.2): it is
refused while it would leave the environment with no administrator who can
sign in. Re-activation is never refused.
**Deactivating the break-glass set is refused.** Switching `admin_full_access`
off un-makes every platform admin who holds it *through a grant row*, in one
write — and re-activating it needs the permission just lost. That write is
judged like deleting or renaming the row (ADR-0024 D5.2): it is refused while
it would leave the environment with no administrator who can sign in. The
enumeration behind that judgement counts **both** anchors, so an environment
whose administrators are configured rather than granted is not empty, and the
write is permitted there. Re-activation is never refused.

Deactivation is an incident-response control, so what it does **not** touch is
deliberate. Administration surfaces keep listing and editing a deactivated
Expand Down
35 changes: 35 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -153,6 +153,41 @@ Auto-seeded on boot by `plugin-security`
| `member_default` | Per-org | Standard end-user CRUD; **writes owner-scoped** via `positions: ['org_member']`-domained RLS | The additive baseline — applies to every authenticated request *in addition to* explicit grants (ADR-0090 D5) |
| `viewer_readonly` | Per-org | Read access only | Auditors / read-only stakeholders |

**Who holds `admin_full_access` is not (only) a grant row.** `PLATFORM_ADMIN`
standing is derived at a single site from **two anchors**, and either one is
sufficient:

- **Configuration — `OS_PLATFORM_OWNER_EMAIL`.** The deployment declares its
administrators as one email address, or a comma-separated list of them. A
caller holds standing when their **own stored `sys_user` row** carries a
declared address *and* that row reads **email-verified**; an unverified
account holding a declared address confers nothing, so registering the
operator's address first gains an attacker no standing. Both sides are
compared trimmed and lower-cased, so `Ada@Example.com` and `ada@example.com`
are one administrator, not two half-matches. Nothing is stored and nothing is
granted — the answer is recomputed from configuration on every resolution.
- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set`
row with `organization_id = NULL`, the original anchor. It still confers
standing, and it is **deprecated**: a deployment resolving through it logs a
pointer, once per process, at the configuration line that re-anchors it.

Which anchor a fresh deployment gets depends on its
[tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first
human account to register is still promoted and its grant row written. Under
the walled postures (`group` / `isolated`) **no grant row is ever written** —
first-registrant promotion is removed there, because on a deployment with
self-registration reachable, whoever signs up first would otherwise receive
cross-tenant access. Administrators come from configuration alone. The operator
procedure, including what an unusable value does, is on
[Self-hosting → First boot](/docs/deployment/self-hosting#first-boot-create-the-admin).

The resolved list is **readable, never writable**: a `platformAdmin` service
answers the configured addresses and, per address, whether an account exists,
whether it is verified, and which account holds standing — for Setup, discovery
and health surfaces. There is deliberately **no runtime endpoint that changes
who a platform administrator is**; revocation is a configuration change plus a
process reload.

## Assigning permission sets

Grants are plain data rows:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
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
3 changes: 2 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_AUTH_URL` | url | `http://localhost:<OS_PORT>` | Public base URL of the auth server. Required behind a proxy or in production. |
| `OS_AUTH_SECRET` | string | auto-generated (dev) | Secret used to sign sessions and cookies. **Required** in production. |
| `OS_AUTH_TWO_FACTOR` | boolean | `false` | Enable the low-level better-auth two-factor plugin. Keep disabled unless your UI handles enrollment, login challenge, and backup-code recovery. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. The very first user can still sign up to bootstrap admin. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. Under the `single` posture the very first user can still sign up to bootstrap admin; under the walled postures no sign-up is ever promoted, so this leaves the deployment dependent on `OS_PLATFORM_OWNER_EMAIL` alone. |
| `OS_AUTH_EMAIL_PASSWORD_ENABLED` | boolean | settings default | Settings env override for `auth.email_password_enabled`. Controls local email/password login. |
| `OS_AUTH_SIGNUP_ENABLED` | boolean | settings default | Settings env override for `auth.signup_enabled`. Takes precedence over UI settings and is preferred over `OS_DISABLE_SIGNUP`. |
| `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` | boolean | settings default | Settings env override for `auth.require_email_verification`. |
Expand All@@ -86,6 +86,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = <active organization>`, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. |
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. |
| `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). |
| `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. |
| `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). |
| `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. |
Expand Down
75 changes: 68 additions & 7 deletions content/docs/deployment/self-hosting.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -402,13 +402,74 @@ decrypt each other's secrets. All replicas must share the same

## First boot: create the admin

On a fresh production database there are no users yet. Open the deployment's
root URL and **sign up — the very first account to register becomes the
bootstrap admin** (this works even with `OS_DISABLE_SIGNUP=true`, which only
blocks sign-ups after that first account exists). Do this immediately after
the first deploy, before sharing the URL; then create your real user accounts
and lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` /
[SSO](/docs/permissions/sso) as policy dictates.
How the first administrator is created depends on the deployment's
[tenancy posture](/docs/deployment/tenancy-modes), and the two paths are not
interchangeable.

**`single` (the default) — the first account wins.** On a fresh production
database there are no users yet. Open the deployment's root URL and **sign up —
the very first account to register becomes the bootstrap admin** (this works
even with `OS_DISABLE_SIGNUP=true`, which only blocks sign-ups after that first
account exists). Do this immediately after the first deploy, before sharing the
URL; then create your real user accounts and lock sign-up down via
`OS_AUTH_SIGNUP_ENABLED` / [SSO](/docs/permissions/sso) as policy dictates.

**Walled postures (`group` / `isolated`) — you name the administrators in
configuration.** First-registrant promotion is removed there: with
self-registration reachable, whoever posts to the sign-up endpoint first would
otherwise receive cross-tenant access. No grant row is written on those
postures, ever. Declare the administrators **before first boot** instead:

```bash
# one address, or a comma-separated list
OS_PLATFORM_OWNER_EMAIL=ops@example.com,backup-admin@example.com
```

Then have each of those people register with exactly that address and **verify
their email**. Standing is recomputed on every request from the configured list
and the account's own stored record, so it appears the moment verification
completes — there is nothing to grant and nothing to click.

The rules below are enforced by the runtime, not advisory:

- **Declare more than one address.** A single mailbox is a single point of
human failure; losing it leaves the deployment with no administrator and no
in-product recovery.
- **Verified only.** An account that holds a declared address but whose email
is not verified is **not** an administrator. A record that predates the
verification column, or that arrived through an import without it, reads
unverified rather than verified.
- **Case and spacing do not matter.** Entries are trimmed and lower-cased on
both sides of the comparison, duplicates collapse, and a trailing separator
or a blank entry is ignored.
- **One bad entry refuses the whole variable.** If any entry is not an email
address, the deployment gets **zero** configured administrators — not the
remaining good ones. Dropping just the bad entry would leave a *narrower*
administrator set than you declared with nothing anywhere to notice, which is
the more dangerous failure. The runtime names the offending entry in the log;
fix or remove it, then reload.
- **Empty is fail-closed, and a walled boot refuses it.** Unset or blank under
a walled posture **aborts startup**, naming the variable, rather than
silently reverting to promoting the first registrant. A value that is set but
*refused* for a bad entry gets past that startup check — it is not blank — so
the process starts and then has no administrators. Confirm from the boot log,
not from the fact that the process came up.
- **Revocation is a configuration change plus a reload.** No endpoint, UI
action or API call adds or removes a platform administrator; that surface
deliberately does not exist. Edit the variable and roll the process — the
next resolution reads the new value.
- **You cannot quietly remove the last one through the user record, either.**
Changing the last remaining administrator's email address, or clearing their
verified flag, is refused, and the refusal points at the configuration as the
remedy — the same guard that refuses deleting or banning the last
administrator.

On a walled boot the runtime logs the resolved list: per declared address,
whether an account exists, whether it is verified, and which account holds
standing. That line is the fastest check that the configuration and the real
accounts agree. The same answer is available read-only to Setup, discovery and
health surfaces; see
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets).

Note the production server seeds **no** dev credentials — the
`admin@objectos.ai` / `admin123` account you may know from `os dev` exists only
Expand Down
34 changes: 24 additions & 10 deletions content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,8 +110,14 @@ implementation detail:
D1) is a separate always-first AND conjunct, not an OR-mergeable policy.
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1), which derives
only from an **unscoped `admin_full_access` grant** — a *scoped* grant, or
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1). That posture
is derived at one site from **two anchors**, either of which is sufficient:
the deployment's **configured administrator list** — an address declared in
`OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and
**email-verified** `sys_user` row — and an **unscoped `admin_full_access`
grant** row. See
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets)
for both. A *scoped* grant, or
piecemeal platform capabilities (`studio.access`, `manage_users`, …), grant
Studio/admin *functions* but never widen the tenant data boundary. A tenant
`organization_admin` never crosses it (invariant I1).
Expand DownExpand Up@@ -331,7 +337,10 @@ a row outside its window simply stops resolving — in `resolveAuthzContext`,
the explain engine, sharing-rule position expansion, and (transitively) the
delegated-admin gate's held-scope resolution. No background cleanup job is
involved (ADR-0049); the clock is checked on every resolution. An expired
unscoped `admin_full_access` grant no longer derives `platform_admin`.
unscoped `admin_full_access` grant no longer derives `platform_admin` — a
statement about the *grant* anchor. The configured-administrator anchor is not
a stored grant row at all, so it carries no validity window and nothing here
dates it; it is revoked by changing the configuration.

The explain engine reports an expired-but-present row as a dedicated
contributor state ("held until 2026-08-01 — expired"), so "why did access
Expand DownExpand Up@@ -381,7 +390,10 @@ place, by the same discipline: **resolution-time filtering, fail-closed**, in
- A deactivated **permission set** contributes nothing: not its name, not its
`system_permissions`, not its `tab_permissions`. As with an expired grant, a
deactivated unscoped `admin_full_access` no longer derives `platform_admin`
— the flag is applied *before* the posture is derived, not after.
— the flag is applied *before* the posture is derived, not after. A
**configured** administrator is judged elsewhere: that anchor reads the
deployment's configuration and the caller's own `sys_user` row, never the
catalogue row, so the flag does not decide their standing.
- A deactivated **position** stops carrying its permission sets, and its name
stops appearing in `positions`, so a permission set that merely shares the
position's name cannot resolve through it either.
Expand All@@ -407,12 +419,14 @@ away, so a row that predates the column keeps granting. The same predicate
guard's simulation — a guard that modelled "deactivated" differently from the
resolver would permit exactly the write it exists to refuse.

**Deactivating the break-glass set is refused.** `admin_full_access` is what
makes the environment's platform admins, so switching it off would un-make all
of them in one write — and re-activating it needs the permission just lost.
That write is judged like deleting or renaming the row (ADR-0024 D5.2): it is
refused while it would leave the environment with no administrator who can
sign in. Re-activation is never refused.
**Deactivating the break-glass set is refused.** Switching `admin_full_access`
off un-makes every platform admin who holds it *through a grant row*, in one
write — and re-activating it needs the permission just lost. That write is
judged like deleting or renaming the row (ADR-0024 D5.2): it is refused while
it would leave the environment with no administrator who can sign in. The
enumeration behind that judgement counts **both** anchors, so an environment
whose administrators are configured rather than granted is not empty, and the
write is permitted there. Re-activation is never refused.

Deactivation is an incident-response control, so what it does **not** touch is
deliberate. Administration surfaces keep listing and editing a deactivated
Expand Down
35 changes: 35 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -153,6 +153,41 @@ Auto-seeded on boot by `plugin-security`
| `member_default` | Per-org | Standard end-user CRUD; **writes owner-scoped** via `positions: ['org_member']`-domained RLS | The additive baseline — applies to every authenticated request *in addition to* explicit grants (ADR-0090 D5) |
| `viewer_readonly` | Per-org | Read access only | Auditors / read-only stakeholders |

**Who holds `admin_full_access` is not (only) a grant row.** `PLATFORM_ADMIN`
standing is derived at a single site from **two anchors**, and either one is
sufficient:

- **Configuration — `OS_PLATFORM_OWNER_EMAIL`.** The deployment declares its
administrators as one email address, or a comma-separated list of them. A
caller holds standing when their **own stored `sys_user` row** carries a
declared address *and* that row reads **email-verified**; an unverified
account holding a declared address confers nothing, so registering the
operator's address first gains an attacker no standing. Both sides are
compared trimmed and lower-cased, so `Ada@Example.com` and `ada@example.com`
are one administrator, not two half-matches. Nothing is stored and nothing is
granted — the answer is recomputed from configuration on every resolution.
- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set`
row with `organization_id = NULL`, the original anchor. It still confers
standing, and it is **deprecated**: a deployment resolving through it logs a
pointer, once per process, at the configuration line that re-anchors it.

Which anchor a fresh deployment gets depends on its
[tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first
human account to register is still promoted and its grant row written. Under
the walled postures (`group` / `isolated`) **no grant row is ever written** —
first-registrant promotion is removed there, because on a deployment with
self-registration reachable, whoever signs up first would otherwise receive
cross-tenant access. Administrators come from configuration alone. The operator
procedure, including what an unusable value does, is on
[Self-hosting → First boot](/docs/deployment/self-hosting#first-boot-create-the-admin).

The resolved list is **readable, never writable**: a `platformAdmin` service
answers the configured addresses and, per address, whether an account exists,
whether it is verified, and which account holds standing — for Setup, discovery
and health surfaces. There is deliberately **no runtime endpoint that changes
who a platform administrator is**; revocation is a configuration change plus a
process reload.

## Assigning permission sets

Grants are plain data rows:
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); docs(permissions): re-anchor the platform-admin pages on the landed config derivation, and give OS_PLATFORM_OWNER_EMAIL an operator runbook (L7) by os-project-manager · Pull Request #13659 · objectstack-ai/objectstack · GitHub
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
3 changes: 2 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,7 +76,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `OS_AUTH_URL` | url | `http://localhost:<OS_PORT>` | Public base URL of the auth server. Required behind a proxy or in production. |
| `OS_AUTH_SECRET` | string | auto-generated (dev) | Secret used to sign sessions and cookies. **Required** in production. |
| `OS_AUTH_TWO_FACTOR` | boolean | `false` | Enable the low-level better-auth two-factor plugin. Keep disabled unless your UI handles enrollment, login challenge, and backup-code recovery. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. The very first user can still sign up to bootstrap admin. |
| `OS_DISABLE_SIGNUP` | boolean | `false` | When `true`, block new email/password sign-ups. Under the `single` posture the very first user can still sign up to bootstrap admin; under the walled postures no sign-up is ever promoted, so this leaves the deployment dependent on `OS_PLATFORM_OWNER_EMAIL` alone. |
| `OS_AUTH_EMAIL_PASSWORD_ENABLED` | boolean | settings default | Settings env override for `auth.email_password_enabled`. Controls local email/password login. |
| `OS_AUTH_SIGNUP_ENABLED` | boolean | settings default | Settings env override for `auth.signup_enabled`. Takes precedence over UI settings and is preferred over `OS_DISABLE_SIGNUP`. |
| `OS_AUTH_REQUIRE_EMAIL_VERIFICATION` | boolean | settings default | Settings env override for `auth.require_email_verification`. |
Expand All@@ -86,6 +86,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
| `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. |
| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = <active organization>`, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. |
| `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. |
| `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). |
| `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. |
| `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). |
| `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. |
Expand Down
75 changes: 68 additions & 7 deletions content/docs/deployment/self-hosting.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -402,13 +402,74 @@ decrypt each other's secrets. All replicas must share the same

## First boot: create the admin

On a fresh production database there are no users yet. Open the deployment's
root URL and **sign up — the very first account to register becomes the
bootstrap admin** (this works even with `OS_DISABLE_SIGNUP=true`, which only
blocks sign-ups after that first account exists). Do this immediately after
the first deploy, before sharing the URL; then create your real user accounts
and lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` /
[SSO](/docs/permissions/sso) as policy dictates.
How the first administrator is created depends on the deployment's
[tenancy posture](/docs/deployment/tenancy-modes), and the two paths are not
interchangeable.

**`single` (the default) — the first account wins.** On a fresh production
database there are no users yet. Open the deployment's root URL and **sign up —
the very first account to register becomes the bootstrap admin** (this works
even with `OS_DISABLE_SIGNUP=true`, which only blocks sign-ups after that first
account exists). Do this immediately after the first deploy, before sharing the
URL; then create your real user accounts and lock sign-up down via
`OS_AUTH_SIGNUP_ENABLED` / [SSO](/docs/permissions/sso) as policy dictates.

**Walled postures (`group` / `isolated`) — you name the administrators in
configuration.** First-registrant promotion is removed there: with
self-registration reachable, whoever posts to the sign-up endpoint first would
otherwise receive cross-tenant access. No grant row is written on those
postures, ever. Declare the administrators **before first boot** instead:

```bash
# one address, or a comma-separated list
OS_PLATFORM_OWNER_EMAIL=ops@example.com,backup-admin@example.com
```

Then have each of those people register with exactly that address and **verify
their email**. Standing is recomputed on every request from the configured list
and the account's own stored record, so it appears the moment verification
completes — there is nothing to grant and nothing to click.

The rules below are enforced by the runtime, not advisory:

- **Declare more than one address.** A single mailbox is a single point of
human failure; losing it leaves the deployment with no administrator and no
in-product recovery.
- **Verified only.** An account that holds a declared address but whose email
is not verified is **not** an administrator. A record that predates the
verification column, or that arrived through an import without it, reads
unverified rather than verified.
- **Case and spacing do not matter.** Entries are trimmed and lower-cased on
both sides of the comparison, duplicates collapse, and a trailing separator
or a blank entry is ignored.
- **One bad entry refuses the whole variable.** If any entry is not an email
address, the deployment gets **zero** configured administrators — not the
remaining good ones. Dropping just the bad entry would leave a *narrower*
administrator set than you declared with nothing anywhere to notice, which is
the more dangerous failure. The runtime names the offending entry in the log;
fix or remove it, then reload.
- **Empty is fail-closed, and a walled boot refuses it.** Unset or blank under
a walled posture **aborts startup**, naming the variable, rather than
silently reverting to promoting the first registrant. A value that is set but
*refused* for a bad entry gets past that startup check — it is not blank — so
the process starts and then has no administrators. Confirm from the boot log,
not from the fact that the process came up.
- **Revocation is a configuration change plus a reload.** No endpoint, UI
action or API call adds or removes a platform administrator; that surface
deliberately does not exist. Edit the variable and roll the process — the
next resolution reads the new value.
- **You cannot quietly remove the last one through the user record, either.**
Changing the last remaining administrator's email address, or clearing their
verified flag, is refused, and the refusal points at the configuration as the
remedy — the same guard that refuses deleting or banning the last
administrator.

On a walled boot the runtime logs the resolved list: per declared address,
whether an account exists, whether it is verified, and which account holds
standing. That line is the fastest check that the configuration and the real
accounts agree. The same answer is available read-only to Setup, discovery and
health surfaces; see
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets).

Note the production server seeds **no** dev credentials — the
`admin@objectos.ai` / `admin123` account you may know from `os dev` exists only
Expand Down
34 changes: 24 additions & 10 deletions content/docs/permissions/authorization.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,8 +110,14 @@ implementation detail:
D1) is a separate always-first AND conjunct, not an OR-mergeable policy.
`viewAllRecords` / `modifyAllRecords` (super-user bypass, posture-gated)
short-circuit the object's *business* RLS. Crossing the **tenant wall**,
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1), which derives
only from an **unscoped `admin_full_access` grant** — a *scoped* grant, or
though, requires the **`PLATFORM_ADMIN` posture** (ADR-0099 D1). That posture
is derived at one site from **two anchors**, either of which is sufficient:
the deployment's **configured administrator list** — an address declared in
`OS_PLATFORM_OWNER_EMAIL`, matched against the caller's own stored and
**email-verified** `sys_user` row — and an **unscoped `admin_full_access`
grant** row. See
[who holds `admin_full_access`](/docs/permissions/permission-sets#built-in-permission-sets)
for both. A *scoped* grant, or
piecemeal platform capabilities (`studio.access`, `manage_users`, …), grant
Studio/admin *functions* but never widen the tenant data boundary. A tenant
`organization_admin` never crosses it (invariant I1).
Expand DownExpand Up@@ -331,7 +337,10 @@ a row outside its window simply stops resolving — in `resolveAuthzContext`,
the explain engine, sharing-rule position expansion, and (transitively) the
delegated-admin gate's held-scope resolution. No background cleanup job is
involved (ADR-0049); the clock is checked on every resolution. An expired
unscoped `admin_full_access` grant no longer derives `platform_admin`.
unscoped `admin_full_access` grant no longer derives `platform_admin` — a
statement about the *grant* anchor. The configured-administrator anchor is not
a stored grant row at all, so it carries no validity window and nothing here
dates it; it is revoked by changing the configuration.

The explain engine reports an expired-but-present row as a dedicated
contributor state ("held until 2026-08-01 — expired"), so "why did access
Expand DownExpand Up@@ -381,7 +390,10 @@ place, by the same discipline: **resolution-time filtering, fail-closed**, in
- A deactivated **permission set** contributes nothing: not its name, not its
`system_permissions`, not its `tab_permissions`. As with an expired grant, a
deactivated unscoped `admin_full_access` no longer derives `platform_admin`
— the flag is applied *before* the posture is derived, not after.
— the flag is applied *before* the posture is derived, not after. A
**configured** administrator is judged elsewhere: that anchor reads the
deployment's configuration and the caller's own `sys_user` row, never the
catalogue row, so the flag does not decide their standing.
- A deactivated **position** stops carrying its permission sets, and its name
stops appearing in `positions`, so a permission set that merely shares the
position's name cannot resolve through it either.
Expand All@@ -407,12 +419,14 @@ away, so a row that predates the column keeps granting. The same predicate
guard's simulation — a guard that modelled "deactivated" differently from the
resolver would permit exactly the write it exists to refuse.

**Deactivating the break-glass set is refused.** `admin_full_access` is what
makes the environment's platform admins, so switching it off would un-make all
of them in one write — and re-activating it needs the permission just lost.
That write is judged like deleting or renaming the row (ADR-0024 D5.2): it is
refused while it would leave the environment with no administrator who can
sign in. Re-activation is never refused.
**Deactivating the break-glass set is refused.** Switching `admin_full_access`
off un-makes every platform admin who holds it *through a grant row*, in one
write — and re-activating it needs the permission just lost. That write is
judged like deleting or renaming the row (ADR-0024 D5.2): it is refused while
it would leave the environment with no administrator who can sign in. The
enumeration behind that judgement counts **both** anchors, so an environment
whose administrators are configured rather than granted is not empty, and the
write is permitted there. Re-activation is never refused.

Deactivation is an incident-response control, so what it does **not** touch is
deliberate. Administration surfaces keep listing and editing a deactivated
Expand Down
35 changes: 35 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -153,6 +153,41 @@ Auto-seeded on boot by `plugin-security`
| `member_default` | Per-org | Standard end-user CRUD; **writes owner-scoped** via `positions: ['org_member']`-domained RLS | The additive baseline — applies to every authenticated request *in addition to* explicit grants (ADR-0090 D5) |
| `viewer_readonly` | Per-org | Read access only | Auditors / read-only stakeholders |

**Who holds `admin_full_access` is not (only) a grant row.** `PLATFORM_ADMIN`
standing is derived at a single site from **two anchors**, and either one is
sufficient:

- **Configuration — `OS_PLATFORM_OWNER_EMAIL`.** The deployment declares its
administrators as one email address, or a comma-separated list of them. A
caller holds standing when their **own stored `sys_user` row** carries a
declared address *and* that row reads **email-verified**; an unverified
account holding a declared address confers nothing, so registering the
operator's address first gains an attacker no standing. Both sides are
compared trimmed and lower-cased, so `Ada@Example.com` and `ada@example.com`
are one administrator, not two half-matches. Nothing is stored and nothing is
granted — the answer is recomputed from configuration on every resolution.
- **An unscoped `admin_full_access` grant row** — a `sys_user_permission_set`
row with `organization_id = NULL`, the original anchor. It still confers
standing, and it is **deprecated**: a deployment resolving through it logs a
pointer, once per process, at the configuration line that re-anchors it.

Which anchor a fresh deployment gets depends on its
[tenancy posture](/docs/deployment/tenancy-modes). Under `single` the first
human account to register is still promoted and its grant row written. Under
the walled postures (`group` / `isolated`) **no grant row is ever written** —
first-registrant promotion is removed there, because on a deployment with
self-registration reachable, whoever signs up first would otherwise receive
cross-tenant access. Administrators come from configuration alone. The operator
procedure, including what an unusable value does, is on
[Self-hosting → First boot](/docs/deployment/self-hosting#first-boot-create-the-admin).

The resolved list is **readable, never writable**: a `platformAdmin` service
answers the configured addresses and, per address, whether an account exists,
whether it is verified, and which account holds standing — for Setup, discovery
and health surfaces. There is deliberately **no runtime endpoint that changes
who a platform administrator is**; revocation is a configuration change plus a
process reload.

## Assigning permission sets

Grants are plain data rows:
Expand Down
Loading