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
61 changes: 61 additions & 0 deletions content/docs/references/identity/eval-user.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
title: Eval User
description: Eval User protocol schemas
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs go in content/docs/guides/. */}

EvalUser — the one user-context contract (ADR-0068 D1).

The signed-in user exposed to every predicate surface (server formula, server

RLS, client UI gates) under the canonical variable name `current_user`

(aliases `user`, `ctx.user`) with an **identical shape**. A predicate such as

`current_user.roles.exists(r, r == 'org_admin')` (or

`'org_admin' in current_user.roles`) therefore evaluates identically wherever

it is written.

`roles: string[]` is the **only canonical** role field. Singular `role` is NOT

part of this contract — its legacy "overwritten to 'admin' on promotion"

behavior is the footgun this eliminates.

@see docs/adr/0068-unified-user-context-and-built-in-identity-roles.md

<Callout type="info">
**Source:** `packages/spec/src/identity/eval-user.zod.ts`
</Callout>

## TypeScript Usage

```typescript
import { EvalUser } from '@objectstack/spec/identity';
import type { EvalUser } from '@objectstack/spec/identity';

// Validate data
const result = EvalUser.parse(data);
```

---

## EvalUser

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **id** | `string` | ✅ | User ID |
| **name** | `string` | optional | Display name |
| **email** | `string` | optional | Email address |
| **roles** | `string[]` | ✅ | Canonical role names assigned to the user (scope-resolved) |
| **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in roles. Deprecated. |
| **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) |


---

1 change: 1 addition & 0 deletions content/docs/references/identity/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ description: Complete reference for all identity protocol schemas
This section contains all protocol schemas for the identity layer of ObjectStack.

<Cards>
<Card href="/docs/references/identity/eval-user" title="Eval User" description="Source: packages/spec/src/identity/eval-user.zod.ts" />
<Card href="/docs/references/identity/identity" title="Identity" description="Source: packages/spec/src/identity/identity.zod.ts" />
<Card href="/docs/references/identity/organization" title="Organization" description="Source: packages/spec/src/identity/organization.zod.ts" />
<Card href="/docs/references/identity/role" title="Role" description="Source: packages/spec/src/identity/role.zod.ts" />
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/identity/meta.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"title": "Identity Protocol",
"pages": [
"eval-user",
"identity",
"organization",
"role",
Expand Down
43 changes: 42 additions & 1 deletion packages/formula/src/stdlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import type { Environment } from '@marcbachmann/cel-js';

import type { EvalContext } from './types';
import { createEvalUser, type EvalUser } from '@objectstack/spec';

/**
* Calendar-day parts (y/m/d) of an instant *as seen in a timezone*
Expand Down Expand Up @@ -266,6 +267,34 @@ export function registerNumericCoercions(env: Environment): Environment {
return env;
}

/**
* Normalize the loosely-typed EvalContext user into the canonical EvalUser
* (ADR-0068). `roles` is preferred; a legacy singular `role` is folded in so
* existing call sites keep working.
*/
function toEvalUser(u: NonNullable<EvalContext['user']>): EvalUser {
const legacyRole = typeof u.role === 'string' && u.role ? [u.role] : [];
const roles = Array.isArray(u.roles) ? (u.roles as string[]) : [];
const canonical = createEvalUser({
id: u.id,
name: typeof u.name === 'string' ? u.name : undefined,
email: typeof u.email === 'string' ? u.email : undefined,
roles: [...roles, ...legacyRole],
organizationId:
typeof u.organizationId === 'string' || u.organizationId === null
? (u.organizationId as string | null)
: undefined,
});
// Back-compat: keep the DEPRECATED singular `role` readable so existing
// predicates (`os.user.role`, `current_user.role`) keep resolving during the
// ADR-0068 migration window. `roles[]` is the canonical surface; the footgun
// ADR-0068 removes is the server-side OVERWRITE of `role`, not read access.
if (typeof u.role === 'string' && u.role) {
(canonical as EvalUser & { role?: string }).role = u.role;
}
return canonical;
}

/**
* Build the variable scope for a single evaluation. Absent fields are simply
* not bound — CEL macros (`has(record.foo)`) handle missing-key safely.
Expand All @@ -279,7 +308,19 @@ export function buildScope(ctx: EvalContext): Record<string, unknown> {

// Namespaced data — written as `os.user.id`, `os.env`, etc. in CEL.
const os: Record<string, unknown> = {};
if (ctx.user !== undefined) os.user = ctx.user;
if (ctx.user !== undefined) {
// ADR-0068: one canonical EvalUser under every alias (`current_user`,
// `user`, `ctx.user`, `os.user`) — the SAME object, so a predicate
// evaluates identically wherever it is authored.
const currentUser = toEvalUser(ctx.user);
scope.current_user = currentUser;
scope.user = currentUser;
scope.ctx = {
...(typeof scope.ctx === 'object' && scope.ctx !== null ? scope.ctx : {}),
user: currentUser,
};
os.user = currentUser;
}
if (ctx.org !== undefined) os.org = ctx.org;
if (ctx.env !== undefined) os.env = ctx.env;
if (Object.keys(os).length > 0) scope.os = os;
Expand Down
15 changes: 14 additions & 1 deletion packages/formula/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,22 @@ export interface EvalContext {
* Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive.
*/
timezone?: string;
/** Current authenticated subject (hook / action / view contexts). */
/**
* Current authenticated subject (hook / action / view contexts).
*
* ADR-0068: the canonical user contract is {@link EvalUser} from
* `@objectstack/spec`, surfaced to predicates as `current_user` (aliases
* `user`, `ctx.user`). `roles: string[]` is the only canonical role field;
* the singular `role` is deprecated (its "overwritten to 'admin' on
* promotion" behavior is the footgun ADR-0068 eliminates).
*/
user?: {
id: string;
/** CANONICAL (ADR-0068). Scope-resolved role names assigned to the user. */
roles?: string[];
/** Active organization ID (null = platform / unscoped). */
organizationId?: string | null;
/** @deprecated ADR-0068 — use {@link roles}. Retained for back-compat only. */
role?: string;
email?: string;
[key: string]: unknown;
Expand Down
58 changes: 57 additions & 1 deletion packages/formula/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ export interface ExprSchemaHint {
* did-you-mean *warning*. (Default.)
*/
scope?: 'record' | 'flattened';
/**
* ADR-0068 D4 — the closed catalog of valid role names (built-in + declared).
* When supplied, a role-membership predicate testing a role NOT in this set
* (e.g. `'org_admni' in current_user.roles`) is flagged as an error. Closes
* the AI-hallucination hole where a model invents a plausible-but-nonexistent
* role that then silently never matches. Absent => role checks are skipped.
*/
roleCatalog?: readonly string[];
}

export interface ExprValidationError {
Expand Down Expand Up @@ -146,6 +154,51 @@ function levenshtein(a: string, b: string): number {
return dp[m];
}

// ADR-0068 D4 — role-membership predicate heads: a role NAME literal used in a
// membership test against a user subject's `.roles` (or the deprecated singular
// `.role`). Matched names are validated against the closed role catalog.
const ROLE_IN_RE = /(['"])([a-z0-9_]+)\1\s+in\s+(?:current_user|user|ctx\.user)\.roles\b/g;
const ROLE_CONTAINS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*contains\(\s*(['"])([a-z0-9_]+)\1\s*\)/g;
// Bounded quantifiers ({0,N}, not * / *?) keep this linear: a CEL `exists`
// body is tiny in practice, and unbounded greedy/lazy scanners here backtrack
// polynomially (O(n^2)) on adversarial input like repeated `user.roles.exists(`
// (ADR-0068 D4 ReDoS hardening). The pre-`==` class excludes `=` so the bounded
// run stops cleanly before the operator without a lazy quantifier.
const ROLE_EXISTS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*exists\s*\([^,)]{0,64},[^)=]{0,128}==\s*(['"])([a-z0-9_]+)\1/g;
const ROLE_EQ_RE = /(?:current_user|user|ctx\.user)\.role\s*==\s*(['"])([a-z0-9_]+)\1/g;

/**
* Flag role-membership predicates referencing a role outside the closed catalog
* (ADR-0068 D4 — anti-hallucination). No-op when no `roleCatalog` is supplied.
*/
function checkRoleCatalog(
source: string,
schema: ExprSchemaHint | undefined,
errors: ExprValidationError[],
): void {
const catalog = schema?.roleCatalog;
if (!catalog || catalog.length === 0) return;
const known = new Set(catalog);
const seen = new Set<string>();
for (const re of [ROLE_IN_RE, ROLE_CONTAINS_RE, ROLE_EXISTS_RE, ROLE_EQ_RE]) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(source)) !== null) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const name = m[2];
if (known.has(name) || seen.has(name)) continue;
seen.add(name);
const suggestion = nearest(name, catalog);
errors.push({
source,
message:
`unknown role \`${name}\` — not a defined role` +
(suggestion ? `; did you mean \`${suggestion}\`?` : '.') +
` Valid roles: ${catalog.join(', ')}.`,
});
}
}
}

/**
* Validate one expression for a given field role. Never throws — returns a
* structured result. Call sites decide whether to throw (build/registration)
Expand Down Expand Up @@ -194,6 +247,7 @@ export function validateExpression(
});
} else {
checkFieldExistence(source, schema, errors);
checkRoleCatalog(source, schema, errors);
if (schema?.scope === 'record') {
// In a `record`-scoped site a bare top-level identifier is a silent bug —
// it must be `record.<field>` (#1928). Hard error.
Expand Down Expand Up @@ -246,12 +300,14 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
dialect: 'cel' | 'template';
fields: string[];
roots: string[];
roles: string[];
functions: string[];
} {
return {
dialect: expectedDialect(role),
fields: [...(schema?.fields ?? [])],
roots: ['record', 'previous', 'input', 'os', 'vars'],
roots: ['record', 'previous', 'input', 'os', 'current_user', 'user', 'vars'],
roles: [...(schema?.roleCatalog ?? [])],
functions: CEL_STDLIB_FUNCTIONS,
};
}
Expand Down
19 changes: 11 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1319,26 +1319,29 @@ describe('AuthManager', () => {
expect(result.user.roles).toEqual(['manager']);
});

it('keeps stored business roles in roles[] when promoting a platform admin', async () => {
it('appends platform_admin to roles[] without overwriting role when promoting a platform admin', async () => {
const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true }));
const result = await callback({
user: { id: 'u-1', email: 'a@b.com', role: 'manager' },
session: {},
});
// Promotion replaces `role` (existing semantics) but the stored
// business role survives in the array.
expect(result.user.role).toBe('admin');
expect(result.user.roles).toEqual(['manager', 'admin']);
// ADR-0068: NO `role:'admin'` overwrite footgun. The deprecated scalar
// keeps its stored value; the canonical platform_admin identity is added
// to roles[], and isPlatformAdmin is a derived alias.
expect(result.user.role).toBe('manager');
expect(result.user.roles).toEqual(['manager', 'platform_admin']);
expect(result.user.isPlatformAdmin).toBe(true);
});

it('does not duplicate admin in roles[] when the stored role already includes it', async () => {
it('splits a multi-token stored role and appends platform_admin without duplicates', async () => {
const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true }));
const result = await callback({
user: { id: 'u-1', email: 'a@b.com', role: 'admin,manager' },
session: {},
});
expect(result.user.role).toBe('admin');
expect(result.user.roles).toEqual(['admin', 'manager']);
expect(result.user.role).toBe('admin,manager');
expect(result.user.roles).toEqual(['admin', 'manager', 'platform_admin']);
expect(result.user.isPlatformAdmin).toBe(true);
});

it('returns the payload untouched when the user has no id', async () => {
Expand Down
50 changes: 28 additions & 22 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
import type { IDataEngine } from '@objectstack/core';
import type { IEmailService } from '@objectstack/spec/contracts';
import { readEnvWithDeprecation } from '@objectstack/types';
import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from '@objectstack/spec';
import { createObjectQLAdapterFactory } from './objectql-adapter.js';
import {
AUTH_USER_CONFIG,
Expand Down Expand Up @@ -967,10 +968,10 @@ export class AuthManager {
// `admin`. Org owners/admins are entitled to manage org-scoped
// metadata such as saved list views, dashboards, etc.
//
// Either path synthesizes `user.role = 'admin'` so the frontend can gate
// metadata-edit affordances uniformly. The raw membership role remains
// available via the `organization` plugin's `member` payload for
// finer-grained checks.
// ADR-0068 D2: rather than synthesizing `user.role = 'admin'`, both paths now
// contribute CANONICAL role names to `user.roles` (platform_admin / org_*),
// and `user.isPlatformAdmin` is a derived alias. The raw membership role
// remains available via the `organization` plugin's `member` payload.
const dataEngine = this.config.dataEngine;
if (dataEngine) {
const { customSession } = await import('better-auth/plugins/custom-session');
Expand Down Expand Up @@ -1000,37 +1001,42 @@ export class AuthManager {
}
};

const isActiveOrgAdmin = async (): Promise<boolean> => {
// ADR-0068 D2 — surface CANONICAL org_* role names (not a boolean flag):
// a membership owner/admin/member maps to org_owner/org_admin/org_member.
const activeOrgRoles = async (): Promise<string[]> => {
try {
const orgId = (session as any)?.activeOrganizationId;
if (!orgId) return false;
if (!orgId) return [];
const members = await dataEngine.find('sys_member', {
where: { user_id: user.id, organization_id: orgId },
limit: 5,
});
return (Array.isArray(members) ? members : []).some((m: any) => {
// better-auth org plugin stores roles as either a single string
// or a comma-separated list (e.g. `"owner,admin"`). Treat any
// membership that includes `owner` or `admin` as administrative.
const out: string[] = [];
for (const m of (Array.isArray(members) ? members : [])) {
const raw = typeof m?.role === 'string' ? m.role : '';
const roles = raw.split(',').map((s: string) => s.trim().toLowerCase());
return roles.includes('owner') || roles.includes('admin');
});
for (const r of raw.split(',').map((s: string) => s.trim()).filter(Boolean)) {
const mapped = mapMembershipRole(r);
if (!out.includes(mapped)) out.push(mapped);
}
}
return out;
} catch {
return false;
return [];
}
};

// ADR-0068 D1/D2 — emit ONE canonical roles[] (identities-as-roles), with
// NO `role:'admin'` overwrite. isPlatformAdmin is a DERIVED alias of
// `'platform_admin' in roles`, retained for back-compat clients.
const platformAdmin = await isPlatformAdmin();
const promote = platformAdmin || (await isActiveOrgAdmin());
const orgRoles = await activeOrgRoles();
const storedRole = typeof (user as any).role === 'string' ? (user as any).role : '';
const roles = storedRole
.split(',')
.map((s: string) => s.trim())
.filter(Boolean);
if (promote && !roles.includes('admin')) roles.push('admin');
if (!promote) return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session };
return { user: { ...user, role: 'admin', roles, isPlatformAdmin: platformAdmin }, session };
const roles = Array.from(new Set([
...storedRole.split(',').map((s: string) => s.trim()).filter(Boolean),
...orgRoles,
...(platformAdmin ? [BUILTIN_ROLE_PLATFORM_ADMIN] : []),
]));
return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session };
}));
}

Expand Down
Loading