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
13 changes: 13 additions & 0 deletions .changeset/22012-isplatformadmin-standing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/spec": patch
---

`EvalUserSchema.isPlatformAdmin` is no longer marked deprecated. Its describe and docblock now say what the key reports: the `PLATFORM_ADMIN` standing of ADR-0095 D3.

Clause-②: no

- The platform resolves that standing per request, from the deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) under every tenancy posture, or from an unscoped `admin_full_access` grant under the `single` posture.
- It is the predicate platform-operator gates read: `current_user.isPlatformAdmin == true` (ADR-0068 D4). The session payload emits it from the posture rung, and the platform-admin route gate reads it.
- The resolver projects the `platform_admin` name into `positions` from the same grant, so the name and the key agree for every genuine administrator. Gate on the key, never on `'platform_admin' in current_user.positions`.
- The old text, "DERIVED alias of 'platform_admin' in positions. Deprecated.", described a reading ADR-0095 D3 superseded. ADR-0068 carries dated notes under D2 and D4 that say so.
- ⛔ Nothing you author changes. There is no schema, type, optionality, default, export or accept-set change, and `createEvalUser` computes exactly what it did. A predicate that already reads `current_user.isPlatformAdmin` keeps working and is the supported form.
10 changes: 6 additions & 4 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -923,10 +923,12 @@ concludes the platform cannot attach an existing user to an organization at all.
With `plugins: { admin: true }` (forced on when SCIM is enabled), platform
admins get email-independent account management on the Users list — the
`create_user` / `set_user_password` actions there drive these endpoints. All
three routes are gated server-side on the platform-admin signals
(`isPlatformAdmin`, the `platform_admin` position, or the legacy `role`
scalar) and drive the better-auth pipeline, so created accounts get a real
scrypt-hashed credential and can sign in immediately.
three routes are gated server-side on the session's `isPlatformAdmin` — the
`PLATFORM_ADMIN` standing of ADR-0095 D3 — with the legacy better-auth `role`
scalar of `admin` still accepted as a back-compat fallback; the
`platform_admin` name in `positions` is not read. They drive the better-auth
pipeline, so created accounts get a real scrypt-hashed credential and can sign
in immediately.

#### Create a User Directly

Expand Down
9 changes: 7 additions & 2 deletions content/docs/permissions/permission-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,13 @@ membership with CEL: `'org_admin' in current_user.positions` or
`current_user.positions.exists(p, p == 'sales_manager')`. The framework-seeded
built-in position names are `platform_admin`, `org_owner`, `org_admin`, and
`org_member`; `everyone`/`guest` are the implicit audience anchors (ADR-0090 D9).
(`current_user.isPlatformAdmin` is a derived, deprecated alias of
`'platform_admin' in current_user.positions`.)
(`current_user.isPlatformAdmin` is the `PLATFORM_ADMIN` standing of
ADR-0095 D3, resolved per request from the deployment's declared platform
administrators — see
[the `PLATFORM_ADMIN` posture](/docs/permissions/authorization#combination-semantics-the-fixed-order).
The `platform_admin` name in `positions` is projected from the same grant, so
the two agree for every genuine administrator, but platform-operator gates read
`current_user.isPlatformAdmin == true` (ADR-0068 D4), never the array.)

## Union semantics — no Profile tier (ADR-0090 D2)

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/identity/eval-user.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const result = EvalUserSchema.parse(data);
| **name** | `string` | optional | Display name |
| **email** | `string` | optional | Email address |
| **positions** | `string[]` | optional (default: `[]`) | Canonical position/identity names the user holds — built-in identity names plus sys_position assignments, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role |
| **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in positions. Deprecated. |
| **isPlatformAdmin** | `boolean` | optional | The PLATFORM_ADMIN standing of ADR-0095 D3: true when the platform resolves the user, per request, to the PLATFORM_ADMIN posture rung — from the deployment's declared administrator list (OS_PLATFORM_OWNER_EMAIL) under every tenancy posture, or from an unscoped admin_full_access grant under the single posture. The predicate platform-operator gates read (current_user.isPlatformAdmin == true, ADR-0068 D4). The resolver projects 'platform_admin' into positions from the same grant, so the two agree for every genuine administrator; gate on this key, never on the positions array. |
| **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) |


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ The model underneath is already correct: `sys_role` is platform-native (ADR-0057
- **`org_*` name normalization**: `resolve-execution-context` and the `customSession` bridge emit `org_owner/admin/member` (not the raw better-auth `owner/admin/member`) so the array is unambiguous and self-documenting.
- **`isPlatformAdmin` → derived alias** (`roles.includes('platform_admin')`). Kept during migration (so the merged objectui#1928 fix and existing predicates keep working), marked deprecated. **No new identity booleans.**

> **Note (2026-10-06) — `isPlatformAdmin` is the `PLATFORM_ADMIN` standing, not a deprecated alias.** The bullet above, TL;DR item 2 and the comment on `isPlatformAdmin` in the D1 sample call the key a derived alias of `'platform_admin' in roles`, marked deprecated. That half is superseded by [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) D3, under which `PLATFORM_ADMIN` derives from held capability grants and never from roles, and by the protocol text of [`EvalUserSchema`](../../packages/spec/src/identity/eval-user.zod.ts#EvalUserSchema): `isPlatformAdmin` is the `PLATFORM_ADMIN` standing of ADR-0095 D3 and carries no deprecation mark (the maintainer's ruling A-lite on [#21886](https://github.com/objectstack-ai/objectstack/issues/21886), [comment 6019378035](https://github.com/objectstack-ai/objectstack/issues/21886#issuecomment-6019378035), 2026-10-06). Core resolves that standing per request at one site, [`resolveUserAuthzGrants`](../../packages/core/src/security/resolve-authz-context.ts#resolveUserAuthzGrants): from the deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) under every tenancy posture, or from the unscoped `admin_full_access` grant this decision names under the `single` posture. It projects the `platform_admin` name into `current_user.positions` from that same grant, so the name and the key agree for every genuine administrator. The session payload, the platform-admin route gate and [`hasPlatformAdminStanding`](../../packages/core/src/security/resolve-authz-context.ts#hasPlatformAdminStanding) judge by the rung, never by the array. The seeded built-in names, the one-way projection and "no new identity booleans" stand.

### D3 — Role-definition authority follows the isolation boundary [ruled]

- **Global / cross-tenant roles**: defined **only** by the super-admin or by **packages** (declared metadata, namespaced `<packageId>.<role>`, `managed_by: 'package'`, auto-created on install via `bootstrapDeclaredRoles`, updated/removed with the package). [existing mechanism, made a rule]
Expand All @@ -83,6 +85,8 @@ The model underneath is already correct: `sys_role` is platform-native (ADR-0057
- **AI-authored in-app gates** use the **env's `sys_role` catalog** as a **closed enum**, given to the AI as grounding *including the system-predefined roles* (`platform_admin`, `org_*`) with their `label`/`description` so it disambiguates (`org_admin` = tenant admin vs `platform_admin` = SaaS operator). The server **validates** authored predicates against the catalog and **rejects unknown role names** (anti-hallucination). Roles — concrete, enumerable, tenant-local vocabulary — are the right grounding for AI in-app authoring.
- **Capability-gating is deferred** to **cloud#474** (platform capability catalog inventory + migration of platform/cross-tenant gating to `requiredPermissions`). Per ADR-0066, *shippable cross-tenant* metadata must ultimately gate on capabilities (stable platform vocabulary), never tenant role names — but that is not needed for the one-operator v1.

> **Note (2026-10-06) — the predicate stands; the equivalence in parentheses does not.** Platform-operator actions gate on `current_user.isPlatformAdmin == true`, as this decision rules. The parenthetical "(≡ `'platform_admin' in roles`)" is superseded together with D2's alias wording (see the note under D2): the key is the `PLATFORM_ADMIN` standing of [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) D3, as [`EvalUserSchema`](../../packages/spec/src/identity/eval-user.zod.ts#EvalUserSchema) describes it, and `'platform_admin' in current_user.positions` is not a standing read. A platform-operator gate reads the key, never the array (the maintainer's ruling A-lite on [#21886](https://github.com/objectstack-ai/objectstack/issues/21886), 2026-10-06).

---

## Scope
Expand Down
43 changes: 43 additions & 0 deletions packages/spec/src/identity/eval-user.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { EvalUserSchema } from './eval-user.zod';

// `EvalUserSchema.isPlatformAdmin` is the predicate platform-operator gates
// read (ADR-0068 D4), and its published description is what an author — human
// or AI — consults before writing that gate. These pins hold the description to
// the standing it actually reports (ADR-0095 D3) and keep the superseded
// "deprecated, derived alias" mark from coming back: a deprecated mark on the
// one key the gates agree on steers authors onto the positions array, which is
// the read every evaluator refuses.

const PUBLISHED = { target: 'draft-2020-12', io: 'input', unrepresentable: 'any' } as const;

function publishedDescription(): string {
const json = z.toJSONSchema(EvalUserSchema, PUBLISHED) as {
properties?: Record<string, { description?: string; deprecated?: boolean }>;
};
const prop = json.properties?.isPlatformAdmin;
expect(prop, 'isPlatformAdmin is missing from the published JSON Schema').toBeDefined();
expect(prop?.deprecated).toBeUndefined();
return prop?.description ?? '';
}

describe('EvalUserSchema.isPlatformAdmin — the published description', () => {
it('names the PLATFORM_ADMIN standing of the posture-ladder decision', () => {
const doc = publishedDescription();
expect(doc).toContain('PLATFORM_ADMIN standing');
expect(doc).toContain('ADR-0095 D3');
expect(doc).toContain('ADR-0068 D4');
});

it('carries no deprecation word and no deprecated flag', () => {
// `publishedDescription()` also asserts the JSON Schema `deprecated` flag is absent.
expect(publishedDescription()).not.toMatch(/deprecat/i);
});

it('publishes the same text the schema declares at the point of use', () => {
expect(EvalUserSchema.shape.isPlatformAdmin.description).toBe(publishedDescription());
});
});
19 changes: 17 additions & 2 deletions packages/spec/src/identity/eval-user.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,23 @@ export const EvalUserSchema = lazySchema(() =>
* `sys_user.role` scalar, which stays published as `user.role`.
*/
positions: z.array(z.string()).default([]).describe('Canonical position/identity names the user holds — built-in identity names plus sys_position assignments, scope-resolved (ADR-0068 D3). The security axis, the same set /auth/me/permissions reports; NOT the better-auth user.role scalar, which remains published as user.role'),
/** DERIVED alias of positions.includes(platform_admin) (ADR-0068 D2). Deprecated surface. */
isPlatformAdmin: z.boolean().optional().describe("DERIVED alias of 'platform_admin' in positions. Deprecated."),
/**
* The `PLATFORM_ADMIN` standing of ADR-0095 D3: true when the platform
* resolves the user, per request, to the `PLATFORM_ADMIN` posture rung —
* from the deployment's declared administrator list
* (`OS_PLATFORM_OWNER_EMAIL`) under every tenancy posture, or from an
* unscoped `admin_full_access` grant under the `single` posture. It is the
* predicate platform-operator gates read
* (`current_user.isPlatformAdmin == true`, ADR-0068 D4).
*
* The resolver projects the `platform_admin` name into `positions` from the
* same grant, so the name and this key agree for every genuine
* administrator. ⛔ Gate on this key, never on
* `'platform_admin' in positions`: the session payload emits this key from
* the rung, and the platform-admin route gate and `hasPlatformAdminStanding`
* judge by the rung too — none of them reads the array.
*/
isPlatformAdmin: z.boolean().optional().describe("The PLATFORM_ADMIN standing of ADR-0095 D3: true when the platform resolves the user, per request, to the PLATFORM_ADMIN posture rung — from the deployment's declared administrator list (OS_PLATFORM_OWNER_EMAIL) under every tenancy posture, or from an unscoped admin_full_access grant under the single posture. The predicate platform-operator gates read (current_user.isPlatformAdmin == true, ADR-0068 D4). The resolver projects 'platform_admin' into positions from the same grant, so the two agree for every genuine administrator; gate on this key, never on the positions array."),
organizationId: z.string().nullable().optional().describe('Active organization ID (null = platform/unscoped)'),
})
);
Expand Down
Loading