diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 707d3e317c..6a653457d2 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -76,7 +76,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `OS_AUTH_URL` | url | `http://localhost:` | 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`. | @@ -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 = `, 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. | diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 1ed1ce73e1..a9a89d252d 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -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 diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 9202b02b11..f40e642b31 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -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). @@ -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 @@ -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. @@ -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 diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index f541025220..a30308f003 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -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: