Skip to content

Commit bbac28f

Browse files
os-zhuangclaude
andcommitted
fix(ai-seat): own ai_access via objectql SysUser def + guarded read (not a better-auth additionalField)
sys_user is a better-auth-MANAGED table and better-auth SELECTs explicit columns. Declaring ai_access as a better-auth additionalField (my first cut) would make getSession query a column that may not exist on every env yet → broken auth on deploy. Safe-by-construction instead: • drop the additionalField (better-auth stays oblivious to the column); • own ai_access on the objectql SysUser object def → provisioned by boot schema-sync, and editable as a normal field (the management UI is a checkbox); • resolveCtx reads it via a GUARDED system query (.catch → []), so a missing column / failed read can only NO-OP — never break auth. Built clean (platform-objects + plugin-auth + plugin-hono-server). Amends the prior additionalField approach on this branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 07a7ba8 commit bbac28f

3 files changed

Lines changed: 44 additions & 18 deletions

File tree

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,19 @@ export const SysUser = ObjectSchema.create({
395395
description: 'When set, the ban auto-clears at this time.',
396396
}),
397397

398+
ai_access: Field.boolean({
399+
label: 'AI Access',
400+
defaultValue: false,
401+
group: 'Admin',
402+
description:
403+
'Whether this user holds an AI seat — grants access to the in-UI AI ' +
404+
'agents (build / ask). The framework synthesizes the `ai_seat` ' +
405+
'capability from this flag (plugin-hono-server resolveCtx). Assignment ' +
406+
'is capped by the licensed / purchased seat count (enforced by ' +
407+
'@objectstack/security-enterprise AiSeatPlugin). Owned by objectql ' +
408+
'(better-auth is oblivious to this column).',
409+
}),
410+
398411
// ── Profile ──────────────────────────────────────────────────
399412
image: Field.url({
400413
label: 'Profile Image',

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -324,15 +324,14 @@ export class AuthManager {
324324
// createAdapterFactory.
325325
user: {
326326
...AUTH_USER_CONFIG,
327-
// System-managed per-user capability flag(s). `ai_access` is the
328-
// env-side AI-seat marker (single-org env DB has only sys_user, so the
329-
// seat lives here as a boolean, not a permission set). better-auth
330-
// includes additionalFields in the session user, so resolveCtx reads
331-
// it with no extra query and synthesizes the `ai_seat` capability.
332-
// `input:false` → not self-settable; set by the entitlement layer/admin.
333-
additionalFields: {
334-
ai_access: { type: 'boolean', required: false, defaultValue: false, input: false },
335-
},
327+
// NOTE: the env-side AI-seat marker `sys_user.ai_access` is deliberately
328+
// NOT declared as a better-auth additionalField. sys_user is a
329+
// better-auth-MANAGED table and better-auth SELECTs explicit columns, so
330+
// declaring it here would make getSession query a column that may not
331+
// exist on every env yet → broken auth. Instead the column is owned by
332+
// the objectql `SysUser` object def (provisioned by boot schema-sync)
333+
// and read by a GUARDED system query in resolveCtx (can only no-op,
334+
// never break auth). better-auth stays oblivious to the extra column.
336335
},
337336
account: {
338337
...AUTH_ACCOUNT_CONFIG,

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -490,15 +490,29 @@ export class HonoServerPlugin implements Plugin {
490490
/* fall back to self-only */
491491
}
492492
}
493-
// Env-side AI-seat marker (ADR: simple model). The single-org
494-
// env DB has no permission-set/org dimension for this — the seat
495-
// is a boolean on sys_user (a better-auth additionalField, so it
496-
// rides on the session user). When set, synthesize the `ai_seat`
497-
// capability so the existing per-agent gate (evaluateAgentAccess
498-
// → requires `ai_seat`) admits the user with no permission-set
499-
// grant. Absent/false → no synthesis (gate denies as before).
500-
if ((session.user as any)?.ai_access === true && !permissions.includes('ai_seat')) {
501-
permissions.push('ai_seat');
493+
// Env-side AI-seat marker (simple model). The single-org env
494+
// DB has no permission-set/org dimension for this — the seat is
495+
// the boolean `sys_user.ai_access`. Read it with a GUARDED system
496+
// query (NOT a better-auth additionalField: sys_user is
497+
// better-auth-managed and better-auth SELECTs explicit columns,
498+
// so an additionalField would make getSession query a possibly-
499+
// missing column → broken auth; a guarded read can only no-op).
500+
// When true, synthesize the `ai_seat` capability so the per-agent
501+
// gate (evaluateAgentAccess → requires `ai_seat`) admits the user
502+
// with no permission-set grant. Absent/false/missing-column →
503+
// no synthesis (deny, as before).
504+
if (!permissions.includes('ai_seat')) {
505+
try {
506+
const ql = getObjectQL();
507+
const sysCtx = { context: { isSystem: true } };
508+
const uRows = await ql?.find?.(
509+
'sys_user',
510+
{ where: { id: userId }, limit: 1, ...sysCtx } as any,
511+
).catch(() => []);
512+
if ((uRows?.[0] as any)?.ai_access === true) permissions.push('ai_seat');
513+
} catch {
514+
/* no ai_access column / query failed → no seat (safe) */
515+
}
502516
}
503517
return {
504518
userId,

0 commit comments

Comments
 (0)