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
11 changes: 11 additions & 0 deletions .changeset/adr-0024-betterauth-obj-visibility.md
Original file line number Diff line number Diff line change
@@ -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).
48 changes: 48 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});

// -------------------------------------------------------------------------
Expand Down
22 changes: 17 additions & 5 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record<string, string[]> }>();
private readonly objectSecurityMetaCache = new Map<string, { isPrivate: boolean; tenancyDisabled: boolean; isBetterAuthManaged: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record<string, string[]> }>();
private dbLoader?: (names: string[]) => Promise<PermissionSet[]>;
private logger: { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void } = {};

Expand Down Expand Up @@ -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<string, string[]> };
: { isPrivate: false, tenancyDisabled: false, isBetterAuthManaged: false, requiredPermissions: [] as string[], fieldRequiredPermissions: {} as Record<string, string[]> };

// 1.5. [ADR-0066 D3] requiredPermissions AND-gate — a capability
// prerequisite checked BEFORE the CRUD grant (ADR §Precedence): a
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1244,7 +1244,7 @@ export class SecurityPlugin implements Plugin {
*/
private async getObjectSecurityMeta(
object: string,
): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record<string, string[]> }> {
): Promise<{ isPrivate: boolean; tenancyDisabled: boolean; isBetterAuthManaged: boolean; requiredPermissions: string[]; fieldRequiredPermissions: Record<string, string[]> }> {
const cached = this.objectSecurityMetaCache.get(object);
if (cached) return cached;
let obj: any = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(object) : null;
Expand All @@ -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)
: [],
Expand Down