Skip to content

Commit 6d32cc6

Browse files
hotlongCopilot
andcommitted
Lock down better-auth managed identity tables; fix RLS null tenant + 403 mapping
Phase-1 RBAC follow-up to address the dashboard sys_user page showing nothing and the request to gate write access to better-auth-owned identity tables. spec - Add Object.managedBy?: 'better-auth' | 'system' | 'platform' (Zod-first). Forward-compatible marker for downstream UI to suppress the generic CRUD form for tables whose lifecycle is owned by an external service. platform-objects - Annotate 17 better-auth-managed identity objects with managedBy: 'better-auth' (sys_user/account/session/organization/member/invitation/ team/team_member/api_key/two_factor/verification/jwks/device_code/ oauth_application/oauth_access_token/oauth_refresh_token/oauth_consent). - default-permission-sets.ts: add BETTER_AUTH_MANAGED_OBJECTS const + helper denyWritesOnManagedObjects() and spread into member_default + viewer_readonly. Server now returns HTTP 403 on direct writes to identity tables; reads remain scoped by sys_user_self / sys_organization_self. plugin-security - RLSCompiler.compileExpression: treat null as "skip this policy" (was only checking undefined). A logged-in user with no active organization caused the wildcard tenant_isolation rule to compile to organization_id = null which (a) returned zero rows from sys_user (no organization_id column) and (b) silently exposed un-tenanted rows on application tables. The compiler now defers to per-object rules. Same fix for the IN(current_user.array) form on empty/missing arrays. - Two regression tests added. rest - mapDataError: short-circuit on PERMISSION_DENIED before the unknown-object heuristic — previously a security message containing the object name and the substring "not" was misclassified as 404. Now returns HTTP 403 with code: 'PERMISSION_DENIED'. - CRUD route catches stop logging 403s as "[REST] Unhandled error:". Tests: plugin-security 32/32, runtime 200/200, rest 53/53, objectql 209/209, spec 6840/6840, platform-objects 100/100. Verified end-to-end against pnpm dev:crm: GET sys_user as authenticated user -> 1 row (own self) GET sys_user/<another-user-id> -> HTTP 404 (RLS hides) POST sys_user (create) -> HTTP 403 PERMISSION_DENIED PATCH sys_user/<self> (update) -> HTTP 403 DELETE sys_user/<self> (delete) -> HTTP 403 Known limitation (unchanged): the dashboard's @object-ui/app-shell is a 3rd-party library and its New/Edit/Delete buttons do not yet respect the new managedBy flag — server-side deny is the source-of-truth gate today. UI hide-buttons land when upstream adopts the flag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e8d57d0 commit 6d32cc6

23 files changed

Lines changed: 168 additions & 6 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111
- **`@objectstack/driver-sql` tests failing in CI** — Added `vitest.config.ts` with resolve aliases for `@objectstack/spec/*` subpath exports (`/data`, `/contracts`, `/system`). Without these aliases, vitest could not resolve the source paths at test time, causing all 81 tests to fail with `ERR_MODULE_NOT_FOUND`.
12+
- **RLS over-filter when user has no active organization** (`@objectstack/plugin-security`) — `RLSCompiler.compileExpression` now treats a `null` user-context value the same as `undefined` and skips the policy. Before this fix, a logged-in user without an active organization (e.g. immediately after sign-up, before joining or creating one) caused the wildcard `tenant_isolation` policy to compile down to `organization_id = null`, which (a) returned zero rows from system tables that lack an `organization_id` column at all (e.g. `sys_user`) and (b) silently exposed any un-tenanted rows on application tables. The compiler now emits no filter for the `null` case, deferring to the more specific per-object rules (`sys_user_self`, `sys_organization_self`, …) and the field-existence safety net in `SecurityPlugin`. Two regression tests added in `packages/plugins/plugin-security/src/security-plugin.test.ts`. **Symptom this resolves:** the `_dashboard/apps/setup/sys_user` page now displays the signed-in user's own row instead of an empty list. Same fix applied to the `IN (current_user.array_property)` form for empty/missing arrays.
13+
- **`PermissionDeniedError` returned as HTTP 404 instead of 403** (`@objectstack/rest`) — `mapDataError` previously fell through to the unknown-object heuristic for any error message containing the object name and the substring `"not"`, which matched the security message `"… on object 'sys_user' is not permitted …"`. The mapper now short-circuits on `error.code === 'PERMISSION_DENIED'` / `error.name === 'PermissionDeniedError'` / message-prefix match before the heuristic and returns HTTP 403 with `code: 'PERMISSION_DENIED'`. The CRUD route catches in `RestServer` also stop logging 403s as `[REST] Unhandled error:` (4 sites updated).
14+
15+
### Added
16+
- **`managedBy` flag on `ObjectSchema`** (`@objectstack/spec`) — `Object.managedBy?: 'better-auth' | 'system' | 'platform'` is a Zod-first marker that downstream UI (Studio / Dashboard / app shell) and CLI tooling should honour to suppress the generic CRUD form for tables whose lifecycle is owned by an external service. Documented in `packages/spec/src/data/object.zod.ts` with the rationale that better-auth-managed identity tables (`sys_user`, `sys_session`, `sys_organization`, …) require side-effects (password hashing, email-verification flows, invitation tokens, refresh-token rotation, JWKS rotation) that the generic record editor cannot reproduce, so writes must go through better-auth's typed SDK methods instead.
17+
- **17 better-auth identity objects annotated with `managedBy: 'better-auth'`** (`@objectstack/platform-objects`) — `sys_user`, `sys_account`, `sys_session`, `sys_organization`, `sys_member`, `sys_invitation`, `sys_team`, `sys_team_member`, `sys_api_key`, `sys_two_factor`, `sys_verification`, `sys_jwks`, `sys_device_code`, `sys_oauth_application`, `sys_oauth_access_token`, `sys_oauth_refresh_token`, `sys_oauth_consent`. (`sys_user_preference` is excluded — it is user-managed.)
18+
- **Server-side write deny for managed identity tables** (`@objectstack/platform-objects`) — `default-permission-sets.ts` now exports `BETTER_AUTH_MANAGED_OBJECTS` + `denyWritesOnManagedObjects()` and spreads it into both `member_default.objects` and `viewer_readonly.objects`, producing per-table `{ allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }` overrides on top of the wildcard. The admin profile (`admin_full_access`) keeps a `'*': { allowCreate: true, modifyAllRecords: true, … }` wildcard so platform admins are unaffected. End-to-end probe verified: an authenticated non-admin user receives HTTP 403 + `code: 'PERMISSION_DENIED'` on `POST /api/v1/data/sys_user`, `POST /api/v1/data/sys_member`, `POST /api/v1/data/sys_organization`, `PATCH /api/v1/data/sys_user/<self>`, and `DELETE /api/v1/data/sys_user/<self>`. Reads remain allowed and are still scoped by RLS (`sys_user_self`, `sys_organization_self`).
1219

1320
### Added
1421
- **Account portal: full better-auth SDK migration** — All user-settings pages (`/account/*`) and the first-run `/setup` wizard now use typed SDK methods from `@objectstack/client` instead of raw `fetch()` calls against `/api/v1/auth/…`. This fixes several silent bugs (wrong parameter names, invented endpoints) and adds missing features:

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const SysAccount = ObjectSchema.create({
1616
pluralLabel: 'Accounts',
1717
icon: 'link',
1818
isSystem: true,
19+
managedBy: 'better-auth',
1920
description: 'OAuth and authentication provider accounts',
2021
titleFormat: '{provider_id} - {account_id}',
2122
compactLayout: ['provider_id', 'user_id', 'account_id'],

packages/platform-objects/src/identity/sys-api-key.object.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const SysApiKey = ObjectSchema.create({
1919
pluralLabel: 'API Keys',
2020
icon: 'key-round',
2121
isSystem: true,
22+
managedBy: 'better-auth',
2223
description: 'API keys for programmatic access',
2324
displayNameField: 'name',
2425
titleFormat: '{name}',

packages/platform-objects/src/identity/sys-device-code.object.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const SysDeviceCode = ObjectSchema.create({
2525
pluralLabel: 'Device Codes',
2626
icon: 'key-round',
2727
isSystem: true,
28+
managedBy: 'better-auth',
2829
description: 'OAuth 2.0 Device Authorization Grant (RFC 8628) pending requests',
2930
titleFormat: '{user_code}',
3031
compactLayout: ['user_code', 'status', 'client_id', 'expires_at'],

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const SysInvitation = ObjectSchema.create({
1616
pluralLabel: 'Invitations',
1717
icon: 'mail',
1818
isSystem: true,
19+
managedBy: 'better-auth',
1920
description: 'Organization invitations for user onboarding',
2021
titleFormat: 'Invitation to {organization_id}',
2122
compactLayout: ['email', 'organization_id', 'status'],

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const SysJwks = ObjectSchema.create({
2020
pluralLabel: 'JWKS Keys',
2121
icon: 'key',
2222
isSystem: true,
23+
managedBy: 'better-auth',
2324
description: 'Asymmetric key pairs used to sign and verify issued JWTs',
2425
compactLayout: ['id', 'created_at', 'expires_at'],
2526

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const SysMember = ObjectSchema.create({
1616
pluralLabel: 'Members',
1717
icon: 'user-check',
1818
isSystem: true,
19+
managedBy: 'better-auth',
1920
description: 'Organization membership records',
2021
titleFormat: '{user_id} in {organization_id}',
2122
compactLayout: ['user_id', 'organization_id', 'role'],

packages/platform-objects/src/identity/sys-oauth-access-token.object.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const SysOauthAccessToken = ObjectSchema.create({
2121
pluralLabel: 'OAuth Access Tokens',
2222
icon: 'ticket',
2323
isSystem: true,
24+
managedBy: 'better-auth',
2425
description: 'Opaque OAuth access tokens issued to client applications',
2526
compactLayout: ['client_id', 'user_id', 'expires_at'],
2627

packages/platform-objects/src/identity/sys-oauth-application.object.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const SysOauthApplication = ObjectSchema.create({
2323
pluralLabel: 'OAuth Applications',
2424
icon: 'key-round',
2525
isSystem: true,
26+
managedBy: 'better-auth',
2627
description: 'Registered OAuth/OIDC client applications',
2728
displayNameField: 'name',
2829
titleFormat: '{name}',

packages/platform-objects/src/identity/sys-oauth-consent.object.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const SysOauthConsent = ObjectSchema.create({
2222
pluralLabel: 'OAuth Consents',
2323
icon: 'shield-check',
2424
isSystem: true,
25+
managedBy: 'better-auth',
2526
description: 'User consent records for OAuth client applications',
2627
compactLayout: ['client_id', 'user_id', 'scopes'],
2728

0 commit comments

Comments
 (0)