diff --git a/.changeset/adr-0024-betterauth-obj-visibility.md b/.changeset/adr-0024-betterauth-obj-visibility.md new file mode 100644 index 0000000000..d36fd7e415 --- /dev/null +++ b/.changeset/adr-0024-betterauth-obj-visibility.md @@ -0,0 +1,11 @@ +--- +'@objectstack/plugin-security': patch +--- + +Security: platform admins see all rows of better-auth-managed identity objects (ADR-0024 / cloud#551) + +Identity tables managed by the auth library (`managedBy: 'better-auth'` — `sys_oauth_application`, `sys_account`, `sys_session`, `sys_sso_provider`, …) are written by better-auth's own adapter with **no tenant context**, so `organization_id` is never stamped and `member_default`'s wildcard `tenant_isolation` RLS denies every row — a platform admin's Setup list (OAuth Applications, Identity Links, …) renders **empty**. + +These objects now get the **same posture-gated superuser bypass** as `private` / `tenancy.enabled:false` objects, so a platform admin's `viewAllRecords` sees all identity rows env-wide. This is **admin-only**: non-admins never trigger the bypass — their `_self` carve-outs / `tenant_isolation` still apply (verified by a regression test that a member stays tenant-scoped), and the flag is deliberately **not** used for the wildcard-policy drop, so it can never leak rows to members. + +Fixes the empty-list symptom across all better-auth-managed Setup objects without per-object `tenancy` changes (which would risk the control plane, where some of these objects ARE cross-env-isolated). diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 07e1719821..8998510059 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -483,6 +483,54 @@ describe('SecurityPlugin', () => { await expect(harness.run(opCtx)).resolves.toBeDefined(); expect(harness.findOne).not.toHaveBeenCalled(); }); + + // ADR-0024 / cloud#551 — `managedBy: 'better-auth'` identity tables get the + // SAME posture-gated superuser bypass as private/non-tenant objects (their + // rows are written without a tenant stamp, so the wildcard tenant_isolation + // would otherwise hide every row from a platform admin). + it('ALLOWS the platform admin and BYPASSES wildcard RLS on a better-auth-managed object', async () => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'admin_full_access' }); + const harness = makeMiddlewareCtx({ + permissionSets: [adminSet], + objectFields: ['id', 'organization_id', 'provider_id'], + schemaExtra: { managedBy: 'better-auth' }, // NOT private, NOT tenancy-disabled + orgScoping: true, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', operation: 'find', ast: { where: undefined }, + context: { userId: 'admin', tenantId: 'org-1', roles: ['admin_full_access'], permissions: [] }, + }; + await expect(harness.run(opCtx)).resolves.toBeDefined(); + expect(opCtx.ast.where).toBeUndefined(); // bypassed — admin sees all identity rows + }); + + it('does NOT bypass for a non-admin on a better-auth-managed object (no leak)', async () => { + const memberWithRls: PermissionSet = { + name: 'member_default', label: 'Member', isProfile: true, + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + rowLevelSecurity: [ + { name: 'tenant_isolation', object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' }, + ], + } as any; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [memberWithRls], + objectFields: ['id', 'organization_id', 'provider_id'], + schemaExtra: { managedBy: 'better-auth' }, + orgScoping: true, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + const opCtx: any = { + object: 'task', operation: 'find', ast: { where: undefined }, + context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] }, + }; + await harness.run(opCtx); + // The member is still tenant-scoped — the managedBy bypass is admin-only. + expect(opCtx.ast.where).toEqual({ organization_id: 'org-1' }); + }); }); // ------------------------------------------------------------------------- diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 1bd24e5897..7425a55078 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -120,7 +120,7 @@ export class SecurityPlugin implements Plugin { * `requiredPermissions` capability contract. Populated lazily from the schema; * cleared on metadata change alongside the other schema-derived caches. */ - private readonly objectSecurityMetaCache = new Map }>(); + private readonly objectSecurityMetaCache = new Map }>(); private dbLoader?: (names: string[]) => Promise; private logger: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void } = {}; @@ -382,7 +382,7 @@ export class SecurityPlugin implements Plugin { const secMeta = permissionSets.length > 0 ? await this.getObjectSecurityMeta(opCtx.object) - : { isPrivate: false, tenancyDisabled: false, requiredPermissions: [] as string[], fieldRequiredPermissions: {} as Record }; + : { isPrivate: false, tenancyDisabled: false, isBetterAuthManaged: false, requiredPermissions: [] as string[], fieldRequiredPermissions: {} as Record }; // 1.5. [ADR-0066 D3] requiredPermissions AND-gate — a capability // prerequisite checked BEFORE the CRUD grant (ADR §Precedence): a @@ -981,7 +981,7 @@ export class SecurityPlugin implements Plugin { // posture gate ensures this never grants cross-tenant visibility on ordinary // tenant business objects. const meta = await this.getObjectSecurityMeta(object); - if (meta.isPrivate || meta.tenancyDisabled) { + if (meta.isPrivate || meta.tenancyDisabled || meta.isBetterAuthManaged) { const isWrite = operation === 'insert' || operation === 'update' || operation === 'delete'; const bypass = isWrite ? this.permissionEvaluator.hasSuperuserWriteBypass(object, permissionSets, { isPrivate: meta.isPrivate }) @@ -1036,7 +1036,7 @@ export class SecurityPlugin implements Plugin { // check) on private/platform-global objects. const meta = await this.getObjectSecurityMeta(object); if ( - (meta.isPrivate || meta.tenancyDisabled) && + (meta.isPrivate || meta.tenancyDisabled || meta.isBetterAuthManaged) && this.permissionEvaluator.hasSuperuserWriteBypass(object, permissionSets, { isPrivate: meta.isPrivate }) ) { return null; @@ -1244,7 +1244,7 @@ export class SecurityPlugin implements Plugin { */ private async getObjectSecurityMeta( object: string, - ): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record }> { + ): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; isBetterAuthManaged: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record }> { const cached = this.objectSecurityMetaCache.get(object); if (cached) return cached; let obj: any = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(object) : null; @@ -1270,6 +1270,18 @@ export class SecurityPlugin implements Plugin { isPrivate: (obj as any)?.access?.default === 'private', tenancyDisabled: (obj as any)?.tenancy?.enabled === false || (obj as any)?.systemFields?.tenant === false, + // Identity-infrastructure tables managed by the auth library + // (`managedBy: 'better-auth'`: sys_user, sys_account, sys_session, + // sys_oauth_application, sys_sso_provider, …). Their rows are written by + // better-auth's own adapter with no tenant context, so `organization_id` + // is never stamped and the wildcard `tenant_isolation` RLS denies them — + // making a platform admin's `viewAllRecords` see an empty list. Treat + // them like the private/non-tenant posture for the SUPERUSER BYPASS ONLY + // (so the platform super-admin sees all identity rows env-wide). This does + // NOT relax member RLS (members never trigger the bypass; their `_self` + // carve-outs / tenant_isolation still apply) and is NOT used for the + // wildcard-policy drop below, so it can never leak rows to non-admins. + isBetterAuthManaged: (obj as any)?.managedBy === 'better-auth', requiredPermissions: Array.isArray((obj as any)?.requiredPermissions) ? (obj as any).requiredPermissions.map(String) : [],