Skip to content

Commit bc03362

Browse files
committed
feat(platform-objects): secure-by-default posture for sensitive system objects (ADR-0066 ④)
The platform's raw secret/credential stores no longer ride the wildcard `'*'` permission grant. `sys_secret`, `sys_jwks`, `sys_verification`, `sys_oauth_access_token`, `sys_oauth_refresh_token`, and `sys_device_code` declare `access: { default: 'private' }` — an ordinary member's generic data-layer access gets 403; platform admins retain access via the posture-gated viewAllRecords/modifyAllRecords superuser bypass. Every runtime consumer is unaffected (verified per reader): better-auth reads via its adapter (system context), `engine.resolveSecret` reads at driver level, SettingsService / the datasource secret-binder read principal-less (middleware falls open for internal calls), API-key auth reads isSystem. - `sys_scim_provider` gains `requiredPermissions: ['manage_platform_settings']` (D3 capability gate), mirroring its sibling `sys_sso_provider`. - Setup nav `nav_jwks` is capability-gated like `nav_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 reads them as the member; row scoping is their guard. A test asserts this exclusion too. - Pinned against regression: platform-objects.test.ts posture suite + the ADR-0056 D10 conformance-matrix row `secure-by-default-posture`. Tests: platform-objects 71, plugin-auth 230, dogfood conformance + anonymous-deny + two-doors e2e, service-settings 129, service-datasource 93 — all green; full turbo build green. Refs #2561 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187GeNqezxV6g5jiLiggfbt
1 parent 466adf6 commit bc03362

13 files changed

Lines changed: 142 additions & 3 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
---
4+
5+
Secure-by-default posture for sensitive system objects (ADR-0066 ④, system-object
6+
slice) — the platform's raw secret/credential stores no longer ride the wildcard
7+
`'*'` permission grant.
8+
9+
`sys_secret` (encrypted settings/datasource secrets), `sys_jwks` (JWT signing
10+
keys), `sys_verification` (password-reset / verify tokens),
11+
`sys_oauth_access_token`, `sys_oauth_refresh_token` (live bearer credentials),
12+
and `sys_device_code` (pending device-grant codes) now declare
13+
`access: { default: 'private' }`: an ordinary member's generic data-layer
14+
read/write gets 403 instead of being covered by `member_default`'s
15+
`'*': allowRead`. Platform admins retain access via the posture-gated
16+
`viewAllRecords`/`modifyAllRecords` superuser bypass, and every runtime consumer
17+
is unaffected — better-auth reads via its adapter (system context),
18+
`engine.resolveSecret` reads at driver level, and SettingsService / the
19+
datasource secret-binder read principal-less (middleware falls open for internal
20+
calls).
21+
22+
`sys_scim_provider` (SCIM bearer-token config) gains the object-level
23+
`requiredPermissions: ['manage_platform_settings']` capability gate, mirroring
24+
its sibling `sys_sso_provider`. The Setup nav item for Signing Keys (JWKS) is
25+
now capability-gated like API Keys, so non-admins don't see a menu entry that
26+
can only 403.
27+
28+
Member self-service objects (`sys_session`, `sys_api_key`,
29+
`sys_oauth_application`, `sys_two_factor`) deliberately keep the public posture —
30+
the Account app ("My Sessions" / "My API Keys" / "My Apps" / 2FA "My
31+
Enrollment") reads them through the generic data layer as the member; row
32+
scoping remains their guard. The declarations are pinned by
33+
`platform-objects.test.ts` and the ADR-0056 D10 conformance-matrix row
34+
`secure-by-default-posture`, so dropping the flag from a secret store fails CI.

content/docs/permissions/authorization.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,16 @@ The complete, prioritized gap map lives in issue **#2561** (the production
181181
object's `requiredPermissions` may be a `string[]` (gates all CRUD) or a
182182
`{ read, create, update, delete }` map (read-open / write-gated), enforced
183183
per operation by plugin-security (`security-plugin.ts` capability AND-gate).
184+
- **Secure-by-default rollout** (ADR-0066 ④) — **system-object slice landed**:
185+
the raw secret/credential stores (`sys_secret`, `sys_jwks`,
186+
`sys_verification`, `sys_oauth_access_token`, `sys_oauth_refresh_token`,
187+
`sys_device_code`) declare `access: { default: 'private' }` — no wildcard
188+
grant reaches them; platform admins retain access via the posture-gated
189+
superuser bypass. `sys_scim_provider` is capability-gated like
190+
`sys_sso_provider`. Member self-service objects (`sys_session`,
191+
`sys_api_key`, `sys_oauth_application`, `sys_two_factor`) deliberately stay
192+
public-posture (the Account app reads them as the member; row scoping is
193+
their guard). Still open: Studio posture surfacing (objectui).
184194
- **Deny/muting subtract layer** (ADR-0005 overlay; ADR-0066 precedence step 4)
185195
— how an environment adjusts a *packaged* set without forking it; deferred
186196
until proven need (ADR-0086 P2).

docs/adr/0066-unified-authorization-model.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ All of this is **open mechanism** (framework `spec` + `plugin-security`): schema
102102

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

105-
- **④ 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.
105+
- **④ 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).
106106
- **⑤ 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`.
107107
- **⑥ 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.
108108
- **⑦ 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).

packages/dogfood/test/authz-conformance.matrix.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
7575
enforcement: 'plugin-security/rls-compiler.ts isSupportedRlsExpression + warn' },
7676
{ id: 'system-permissions', summary: 'systemPermissions / tab-app gating', state: 'enforced',
7777
enforcement: 'rest/rest-server.ts filterAppForUser' },
78+
{ id: 'secure-by-default-posture', summary: 'ADR-0066 ④ — sensitive system objects opt out of the wildcard grant (access.default: private)', state: 'enforced',
79+
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',
80+
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.' },
7881

7982
// ── Experimental — declared, NOT enforced (ADR-0049/0056 D8) ───────────
8083
{ id: 'compliance-configs', summary: 'GDPR/HIPAA/PCI configs', state: 'experimental', note: 'no runtime consumer; marked [EXPERIMENTAL] (D8)' },

packages/platform-objects/src/apps/setup-nav.contributions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,11 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
122122
priority: BASE_PRIORITY,
123123
items: [
124124
{ id: 'nav_oauth_apps', type: 'object', label: 'OAuth Applications', objectName: 'sys_oauth_application', icon: 'app-window' },
125-
{ id: 'nav_jwks', type: 'object', label: 'Signing Keys (JWKS)', objectName: 'sys_jwks', icon: 'key-round' },
125+
// nav_jwks is capability-gated (like nav_api_keys): sys_jwks is
126+
// `access.default:'private'` (ADR-0066 ④ — signing keys), so a
127+
// non-admin's list request 403s server-side; gating the nav item keeps
128+
// the menu honest instead of showing an entry that can only error.
129+
{ id: 'nav_jwks', type: 'object', label: 'Signing Keys (JWKS)', objectName: 'sys_jwks', icon: 'key-round', requiredPermissions: ['manage_platform_settings'] },
126130
// `sys_verification` (email/phone tokens) and `sys_device_code` (OAuth
127131
// device-grant codes) deliberately omit `list` from their `apiMethods`
128132
// (sensitive, ephemeral secrets — not browsable), so an object/list-view

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export const SysDeviceCode = ObjectSchema.create({
2626
icon: 'key-round',
2727
isSystem: true,
2828
managedBy: 'better-auth',
29+
// [ADR-0066 D2/④] Secure-by-default: rows are LIVE pending device-grant
30+
// codes — reading `user_code`/`device_code` lets an attacker hijack a
31+
// pending CLI login. Not covered by the wildcard `'*'` grant; admins retain
32+
// access via the superuser bypass; better-auth reads via its adapter
33+
// (system context), so the device-grant flow is unaffected.
34+
access: { default: 'private' },
2935
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
3036
// but may add overlay row-level config. Use `no-overlay` if you need to
3137
// forbid sys_metadata overlays entirely.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ export const SysJwks = ObjectSchema.create({
2121
icon: 'key',
2222
isSystem: true,
2323
managedBy: 'better-auth',
24+
// [ADR-0066 D2/④] Secure-by-default: rows are the environment's JWT SIGNING
25+
// KEYS (private key material). Not covered by the wildcard `'*'` grant — an
26+
// ordinary member gets 403 from the generic data layer. Platform admins
27+
// (viewAllRecords/modifyAllRecords) retain access via the posture-gated
28+
// superuser bypass; better-auth itself reads via its adapter (system
29+
// context), so token signing/verification is unaffected.
30+
access: { default: 'private' },
2431
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
2532
// but may add overlay row-level config. Use `no-overlay` if you need to
2633
// forbid sys_metadata overlays entirely.

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export const SysOauthAccessToken = ObjectSchema.create({
2222
icon: 'ticket',
2323
isSystem: true,
2424
managedBy: 'better-auth',
25+
// [ADR-0066 D2/④] Secure-by-default: rows are LIVE bearer credentials —
26+
// reading one is session hijack. Not covered by the wildcard `'*'` grant;
27+
// admins retain access via the superuser bypass; better-auth reads via its
28+
// adapter (system context), so OAuth flows are unaffected.
29+
access: { default: 'private' },
2530
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
2631
// but may add overlay row-level config. Use `no-overlay` if you need to
2732
// forbid sys_metadata overlays entirely.

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export const SysOauthRefreshToken = ObjectSchema.create({
2121
icon: 'refresh-cw',
2222
isSystem: true,
2323
managedBy: 'better-auth',
24+
// [ADR-0066 D2/④] Secure-by-default: rows are LIVE long-lived credentials —
25+
// a refresh token mints new access tokens. Not covered by the wildcard `'*'`
26+
// grant; admins retain access via the superuser bypass; better-auth reads
27+
// via its adapter (system context), so OAuth flows are unaffected.
28+
access: { default: 'private' },
2429
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
2530
// but may add overlay row-level config. Use `no-overlay` if you need to
2631
// forbid sys_metadata overlays entirely.

packages/platform-objects/src/identity/sys-scim-provider.object.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ export const SysScimProvider = ObjectSchema.create({
3333
icon: 'users',
3434
isSystem: true,
3535
managedBy: 'better-auth',
36+
// [ADR-0066 D3/④] Admin-only identity config carrying a live credential
37+
// (`scim_token` — the bearer external IdPs authenticate provisioning calls
38+
// with). Object-level capability gate, mirroring the sibling
39+
// `sys_sso_provider`: ordinary members are denied entirely (without it, the
40+
// `member_default` wildcard `'*': allowRead` would expose SCIM connections
41+
// to every authenticated user). better-auth's own endpoints read via a
42+
// system context, so SCIM provisioning is unaffected.
43+
requiredPermissions: ['manage_platform_settings'],
3644
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema.
3745
protection: {
3846
lock: 'full',

0 commit comments

Comments
 (0)