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
34 changes: 34 additions & 0 deletions .changeset/secure-by-default-system-objects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/platform-objects': minor
---

Secure-by-default posture for sensitive system objects (ADR-0066 ④, system-object
slice) — the platform's raw secret/credential stores no longer ride the wildcard
`'*'` permission grant.

`sys_secret` (encrypted settings/datasource secrets), `sys_jwks` (JWT signing
keys), `sys_verification` (password-reset / verify tokens),
`sys_oauth_access_token`, `sys_oauth_refresh_token` (live bearer credentials),
and `sys_device_code` (pending device-grant codes) now declare
`access: { default: 'private' }`: an ordinary member's generic data-layer
read/write gets 403 instead of being covered by `member_default`'s
`'*': allowRead`. Platform admins retain access via the posture-gated
`viewAllRecords`/`modifyAllRecords` superuser bypass, and every runtime consumer
is unaffected — better-auth reads via its adapter (system context),
`engine.resolveSecret` reads at driver level, and SettingsService / the
datasource secret-binder read principal-less (middleware falls open for internal
calls).

`sys_scim_provider` (SCIM bearer-token config) gains the object-level
`requiredPermissions: ['manage_platform_settings']` capability gate, mirroring
its sibling `sys_sso_provider`. The Setup nav item for Signing Keys (JWKS) is
now capability-gated like API Keys, so non-admins don't see a menu entry that
can only 403.

Member self-service objects (`sys_session`, `sys_api_key`,
`sys_oauth_application`, `sys_two_factor`) deliberately keep the public posture —
the Account app ("My Sessions" / "My API Keys" / "My Apps" / 2FA "My
Enrollment") reads them through the generic data layer as the member; row
scoping remains their guard. The declarations are pinned by
`platform-objects.test.ts` and the ADR-0056 D10 conformance-matrix row
`secure-by-default-posture`, so dropping the flag from a secret store fails CI.
10 changes: 10 additions & 0 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@ The complete, prioritized gap map lives in issue **#2561** (the production
object's `requiredPermissions` may be a `string[]` (gates all CRUD) or a
`{ read, create, update, delete }` map (read-open / write-gated), enforced
per operation by plugin-security (`security-plugin.ts` capability AND-gate).
- **Secure-by-default rollout** (ADR-0066 ④) — **system-object slice landed**:
the raw secret/credential stores (`sys_secret`, `sys_jwks`,
`sys_verification`, `sys_oauth_access_token`, `sys_oauth_refresh_token`,
`sys_device_code`) declare `access: { default: 'private' }` — no wildcard
grant reaches them; platform admins retain access via the posture-gated
superuser bypass. `sys_scim_provider` is capability-gated like
`sys_sso_provider`. Member self-service objects (`sys_session`,
`sys_api_key`, `sys_oauth_application`, `sys_two_factor`) deliberately stay
public-posture (the Account app reads them as the member; row scoping is
their guard). Still open: Studio posture surfacing (objectui).
- **Deny/muting subtract layer** (ADR-0005 overlay; ADR-0066 precedence step 4)
— how an environment adjusts a *packaged* set without forking it; deferred
until proven need (ADR-0086 P2).
Expand Down
9 changes: 7 additions & 2 deletions content/docs/ui/setup-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ anchors are:
| **Configuration** (`group_configuration`) | All Settings · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` |
| **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs — `plugin-audit` |
| **Integrations** (`group_integrations`) | `plugin-webhooks` |
| **Advanced** (`group_advanced`) | OAuth Applications · Signing Keys (JWKS) · Verifications · Device Codes · Identity Links · User Preferences — `platform-objects` |
| **Advanced** (`group_advanced`) | OAuth Applications · Signing Keys (JWKS) · Identity Links · User Preferences — `platform-objects` |

The exact rendered menu depends on which capability plugins are loaded.
A few notable entries:
Expand All @@ -66,7 +66,12 @@ A few notable entries:
- **OAuth Applications** (`sys_oauth_application`) — third-party OAuth
client registrations, contributed into `group_advanced`.
- **Signing Keys (JWKS)** (`sys_jwks`) — JWKS keys used for OIDC / JWT
signing, contributed into `group_advanced`.
signing, contributed into `group_advanced`. The nav item is gated on the
`manage_platform_settings` capability, and the object itself is
`access: { default: 'private' }` (ADR-0066 ④) — signing keys are never
covered by the wildcard grant, so non-admins are denied server-side.
(`sys_verification` and `sys_device_code` are deliberately **not** in the
nav: sensitive, ephemeral secrets — not browsable, and also `private`.)
- **Audit Logs** (`sys_audit_log`) — contributed by `plugin-audit` into
`group_diagnostics`. (The `sys_activity` and `sys_comment` objects also
live in `plugin-audit`, but they are not contributed as Setup nav
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0066-unified-authorization-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ All of this is **open mechanism** (framework `spec` + `plugin-security`): schema

Captured for the record; **out of scope for Phases 1–4 above**. Each is anchored to a mainstream-platform precedent.

- **④ Deny-by-default target for sensitive objects.** Salesforce / Dataverse / ServiceNow / SAP are all deny-by-default; ObjectStack stays allow-by-default for *tenant business* objects (low-code ergonomics, à la Airtable/Notion within a workspace) but should make **system / control-plane / sensitive** objects `private` by default, ship genuine reference data (countries, currencies, picklists) as explicit `public`, and surface each object's posture visibly in Studio. The `access` flag (D2) is the primitive; this is a defaults + visibility call, staged per object — no forced migration.
- **④ Deny-by-default target for sensitive objects.** Salesforce / Dataverse / ServiceNow / SAP are all deny-by-default; ObjectStack stays allow-by-default for *tenant business* objects (low-code ergonomics, à la Airtable/Notion within a workspace) but should make **system / control-plane / sensitive** objects `private` by default, ship genuine reference data (countries, currencies, picklists) as explicit `public`, and surface each object's posture visibly in Studio. The `access` flag (D2) is the primitive; this is a defaults + visibility call, staged per object — no forced migration. **System-object slice landed (2026-07):** the raw secret/credential stores — `sys_secret`, `sys_jwks`, `sys_verification`, `sys_oauth_access_token`, `sys_oauth_refresh_token`, `sys_device_code` — declare `access: { default: 'private' }`, and `sys_scim_provider` carries the D3 capability gate (mirroring `sys_sso_provider`); pinned by `platform-objects.test.ts` + the D10 matrix row `secure-by-default-posture`. Member self-service objects (`sys_session`, `sys_api_key`, `sys_oauth_application`, `sys_two_factor`) deliberately stay public-posture — the Account app reads them with a member context; row scoping is their guard. Still open: Studio posture surfacing (objectui) and the explicit-`public` reference-data convention (no such objects ship in framework today).
- **⑤ Per-operation `requiredPermissions`.** Today object-level `requiredPermissions` gates all of CRUD. ERP routinely needs "read-open / write-gated" (Salesforce & Dataverse separate capability by operation). Allow `requiredPermissions` to be either `string[]` (all operations) or a per-operation map `{ read, create, update, delete }`. Field-level (D3) and action-level (D4) requirements already give finer control; this closes the object-level gap. **Landed (2026-07):** spec `ObjectRequiredPermissionsSchema` union (`spec/data/object.zod.ts`); enforced per operation in `plugin-security/security-plugin.ts` (the `all` bucket preserves the array form's gate-everything semantics, so it is backward-compatible). `transfer`/`restore` fold into `update`, `purge` into `delete` via `crudBucketForOperation`.
- **⑥ Capabilities in the expression surface.** Salesforce *Custom Permissions* are referenceable in formulas / validation / flows (`$Permission.X`). Expose the caller's held capabilities to the CEL/predicate surface (ADR-0058) so `visible` / validation / sharing predicates can branch on a capability. High-leverage once D1 makes capabilities first-class.
- **⑦ Permission-set groups + subtractive *muting*.** Pure union does not scale governance ("permission-set explosion"); Salesforce added permission-set-group *muting* precisely to allow taking access away. Roles→permission-sets already bundle; a subtractive/deny layer (precedence step 4) is the missing piece for large-org administration. Pairs with delegated admin (#9).
Expand Down
3 changes: 3 additions & 0 deletions packages/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
enforcement: 'plugin-security/rls-compiler.ts isSupportedRlsExpression + warn' },
{ id: 'system-permissions', summary: 'systemPermissions / tab-app gating', state: 'enforced',
enforcement: 'rest/rest-server.ts filterAppForUser' },
{ id: 'secure-by-default-posture', summary: 'ADR-0066 ④ — sensitive system objects opt out of the wildcard grant (access.default: private)', state: 'enforced',
enforcement: 'plugin-security/permission-evaluator.ts resolveObjectPermission (plain wildcard does not cover a private object) + posture-gated superuser bypass; declarations in platform-objects (sys_secret, sys_jwks, sys_verification, sys_oauth_access_token, sys_oauth_refresh_token, sys_device_code) + sys_scim_provider D3 capability gate',
note: 'Primitive enforcement unit-proven in plugin-security/security-plugin.test.ts (ADR-0066 posture suite); the per-object declarations are pinned by platform-objects.test.ts "secure-by-default posture" so dropping the flag from a secret store fails CI, not review. Member self-service objects (sys_session, sys_api_key, sys_oauth_application, sys_two_factor) deliberately stay public-posture — the Account app reads them with a member context; row scoping (owner/tenant RLS + _self carve-outs) is their guard.' },

// ── Experimental — declared, NOT enforced (ADR-0049/0056 D8) ───────────
{ id: 'compliance-configs', summary: 'GDPR/HIPAA/PCI configs', state: 'experimental', note: 'no runtime consumer; marked [EXPERIMENTAL] (D8)' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
priority: BASE_PRIORITY,
items: [
{ id: 'nav_oauth_apps', type: 'object', label: 'OAuth Applications', objectName: 'sys_oauth_application', icon: 'app-window' },
{ id: 'nav_jwks', type: 'object', label: 'Signing Keys (JWKS)', objectName: 'sys_jwks', icon: 'key-round' },
// nav_jwks is capability-gated (like nav_api_keys): sys_jwks is
// `access.default:'private'` (ADR-0066 ④ — signing keys), so a
// non-admin's list request 403s server-side; gating the nav item keeps
// the menu honest instead of showing an entry that can only error.
{ id: 'nav_jwks', type: 'object', label: 'Signing Keys (JWKS)', objectName: 'sys_jwks', icon: 'key-round', requiredPermissions: ['manage_platform_settings'] },
// `sys_verification` (email/phone tokens) and `sys_device_code` (OAuth
// device-grant codes) deliberately omit `list` from their `apiMethods`
// (sensitive, ephemeral secrets — not browsable), so an object/list-view
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export const SysDeviceCode = ObjectSchema.create({
icon: 'key-round',
isSystem: true,
managedBy: 'better-auth',
// [ADR-0066 D2/④] Secure-by-default: rows are LIVE pending device-grant
// codes — reading `user_code`/`device_code` lets an attacker hijack a
// pending CLI login. Not covered by the wildcard `'*'` grant; admins retain
// access via the superuser bypass; better-auth reads via its adapter
// (system context), so the device-grant flow is unaffected.
access: { default: 'private' },
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
// but may add overlay row-level config. Use `no-overlay` if you need to
// forbid sys_metadata overlays entirely.
Expand Down
7 changes: 7 additions & 0 deletions packages/platform-objects/src/identity/sys-jwks.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export const SysJwks = ObjectSchema.create({
icon: 'key',
isSystem: true,
managedBy: 'better-auth',
// [ADR-0066 D2/④] Secure-by-default: rows are the environment's JWT SIGNING
// KEYS (private key material). Not covered by the wildcard `'*'` grant — an
// ordinary member gets 403 from the generic data layer. Platform admins
// (viewAllRecords/modifyAllRecords) retain access via the posture-gated
// superuser bypass; better-auth itself reads via its adapter (system
// context), so token signing/verification is unaffected.
access: { default: 'private' },
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
// but may add overlay row-level config. Use `no-overlay` if you need to
// forbid sys_metadata overlays entirely.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export const SysOauthAccessToken = ObjectSchema.create({
icon: 'ticket',
isSystem: true,
managedBy: 'better-auth',
// [ADR-0066 D2/④] Secure-by-default: rows are LIVE bearer credentials —
// reading one is session hijack. Not covered by the wildcard `'*'` grant;
// admins retain access via the superuser bypass; better-auth reads via its
// adapter (system context), so OAuth flows are unaffected.
access: { default: 'private' },
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
// but may add overlay row-level config. Use `no-overlay` if you need to
// forbid sys_metadata overlays entirely.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export const SysOauthRefreshToken = ObjectSchema.create({
icon: 'refresh-cw',
isSystem: true,
managedBy: 'better-auth',
// [ADR-0066 D2/④] Secure-by-default: rows are LIVE long-lived credentials —
// a refresh token mints new access tokens. Not covered by the wildcard `'*'`
// grant; admins retain access via the superuser bypass; better-auth reads
// via its adapter (system context), so OAuth flows are unaffected.
access: { default: 'private' },
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
// but may add overlay row-level config. Use `no-overlay` if you need to
// forbid sys_metadata overlays entirely.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ export const SysScimProvider = ObjectSchema.create({
icon: 'users',
isSystem: true,
managedBy: 'better-auth',
// [ADR-0066 D3/④] Admin-only identity config carrying a live credential
// (`scim_token` — the bearer external IdPs authenticate provisioning calls
// with). Object-level capability gate, mirroring the sibling
// `sys_sso_provider`: ordinary members are denied entirely (without it, the
// `member_default` wildcard `'*': allowRead` would expose SCIM connections
// to every authenticated user). better-auth's own endpoints read via a
// system context, so SCIM provisioning is unaffected.
requiredPermissions: ['manage_platform_settings'],
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema.
protection: {
lock: 'full',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export const SysVerification = ObjectSchema.create({
icon: 'shield-check',
isSystem: true,
managedBy: 'better-auth',
// [ADR-0066 D2/④] Secure-by-default: rows are LIVE one-time credentials
// (email/phone verification + password-reset tokens) — reading one is
// account takeover. Not covered by the wildcard `'*'` grant; admins retain
// access via the superuser bypass; better-auth reads via its adapter
// (system context), so verification flows are unaffected.
access: { default: 'private' },
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
// but may add overlay row-level config. Use `no-overlay` if you need to
// forbid sys_metadata overlays entirely.
Expand Down
45 changes: 44 additions & 1 deletion packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import { describe, expect, it } from 'vitest';
import {
SysAccount,
SysApiKey,
SysDeviceCode,
SysInvitation,
SysJwks,
SysMember,
SysOauthAccessToken,
SysOauthApplication,
SysOauthRefreshToken,
SysOrganization,
SysScimProvider,
SysSession,
SysTeam,
SysTeamMember,
Expand All @@ -27,7 +32,7 @@ import {
SysMetadata,
SysMetadataHistoryObject,
} from './metadata/index.js';
import { SysSetting } from './system/index.js';
import { SysSecret, SysSetting } from './system/index.js';
import { ACCOUNT_APP, SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from './apps/index.js';
import { AppSchema } from '@objectstack/spec/ui';

Expand Down Expand Up @@ -63,6 +68,44 @@ describe('@objectstack/platform-objects', () => {
expect((object as any).tableName).toBeUndefined();
});

describe('secure-by-default posture (ADR-0066 ④)', () => {
// Raw secret / live-credential stores are opted OUT of the wildcard `'*'`
// grant: only an explicit per-object grant or the posture-gated superuser
// bypass (viewAllRecords/modifyAllRecords) reaches them. These assertions
// PIN the rollout — dropping the flag from any of these objects silently
// re-exposes signing keys / reset tokens / bearer credentials to every
// authenticated member, so a removal must fail here first (ADR-0078:
// no silent regression to the inert/exposed state).
const privateObjects = [
['SysSecret', SysSecret], // encrypted settings/datasource secrets
['SysJwks', SysJwks], // JWT signing private keys
['SysVerification', SysVerification], // password-reset / verify tokens
['SysOauthAccessToken', SysOauthAccessToken], // live bearer tokens
['SysOauthRefreshToken', SysOauthRefreshToken], // live refresh tokens
['SysDeviceCode', SysDeviceCode], // pending device-grant codes
] as const;

it.each(privateObjects)('%s declares access.default = private', (_exportName, object) => {
expect((object as any).access?.default).toBe('private');
});

it('SysScimProvider is capability-gated like its sibling sys_sso_provider', () => {
// Admin config with an embedded live credential (scim_token) — the D3
// capability gate (not the private posture) is the sso/scim pattern.
expect((SysScimProvider as any).requiredPermissions).toEqual(['manage_platform_settings']);
});

it('member self-service objects deliberately stay on the public posture', () => {
// Account app ("My Sessions" / "My API Keys" / "My Apps") and the 2FA
// "My Enrollment" view read these via the generic data layer with a
// MEMBER context — flipping them private would break self-service.
// Row scoping (owner/tenant RLS + _self carve-outs) is their guard.
for (const object of [SysSession, SysApiKey, SysOauthApplication, SysTwoFactor]) {
expect((object as any).access?.default).not.toBe('private');
}
});
});

describe('sysadmin row actions', () => {
// Setup-App admins must be able to drive the access-control lifecycle
// without dropping to SQL. These assertions lock in the high-traffic
Expand Down
8 changes: 8 additions & 0 deletions packages/platform-objects/src/system/sys-secret.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export const SysSecret = ObjectSchema.create({
icon: 'key',
isSystem: true,
managedBy: 'system',
// [ADR-0066 D2/④] Secure-by-default: the environment's encrypted-secrets
// store (settings/datasource credentials). Not covered by the wildcard `'*'`
// grant — ordinary members get 403 from the generic data layer. Platform
// admins retain access via the posture-gated superuser bypass. Internal
// readers are unaffected: `engine.resolveSecret` reads at DRIVER level,
// SettingsService / the datasource secret-binder read with no principal
// (middleware falls open for principal-less internal calls).
access: { default: 'private' },
description: 'Cipher store referenced by sys_setting handles. Never holds plaintext.',
highlightFields: ['namespace', 'key', 'kms_key_id', 'version', 'rotated_at'],
listViews: {
Expand Down