Skip to content

Commit 5db2742

Browse files
authored
chore(spec): mark PolicySchema properties experimental — kill false compliance (ADR-0049 #1882) (#1923)
1 parent 1366af1 commit 5db2742

4 files changed

Lines changed: 68 additions & 65 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
chore(spec): mark every PolicySchema property `[EXPERIMENTAL — not enforced]` (ADR-0049, #1882). PolicySchema (password/network/session/audit + `forceMfa`, IP allow-list, retention) is parsed but has no runtime consumer — `better-auth` runs hardcoded defaults. The per-property markers make the no-op explicit in the generated reference docs (previously `forceMfa` read "Require 2FA for all users" with no caveat — a false-compliance signal) and to the spec-liveness gate, which now classifies them `experimental` rather than `dead`. Description-only; no behaviour change.

content/docs/references/security/policy.mdx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ const result = AuditPolicy.parse(data);
2929

3030
| Property | Type | Required | Description |
3131
| :--- | :--- | :--- | :--- |
32-
| **logRetentionDays** | `number` || |
33-
| **sensitiveFields** | `string[]` || Fields to redact in logs (e.g. password, ssn) |
34-
| **captureRead** | `boolean` || Log read access (High volume!) |
32+
| **logRetentionDays** | `number` || [EXPERIMENTAL — not enforced] Days to retain audit logs |
33+
| **sensitiveFields** | `string[]` || [EXPERIMENTAL — not enforced] Fields to redact in logs (e.g. password, ssn) |
34+
| **captureRead** | `boolean` || [EXPERIMENTAL — not enforced] Log read access (High volume!) |
3535

3636

3737
---
@@ -42,9 +42,9 @@ const result = AuditPolicy.parse(data);
4242

4343
| Property | Type | Required | Description |
4444
| :--- | :--- | :--- | :--- |
45-
| **trustedRanges** | `string[]` || CIDR ranges allowed to access (e.g. 10.0.0.0/8) |
46-
| **blockUnknown** | `boolean` || Block all IPs not in trusted ranges |
47-
| **vpnRequired** | `boolean` || |
45+
| **trustedRanges** | `string[]` || [EXPERIMENTAL — not enforced] CIDR ranges allowed to access (e.g. 10.0.0.0/8) |
46+
| **blockUnknown** | `boolean` || [EXPERIMENTAL — not enforced] Block all IPs not in trusted ranges |
47+
| **vpnRequired** | `boolean` || [EXPERIMENTAL — not enforced] Require VPN to access |
4848

4949

5050
---
@@ -55,13 +55,13 @@ const result = AuditPolicy.parse(data);
5555

5656
| Property | Type | Required | Description |
5757
| :--- | :--- | :--- | :--- |
58-
| **minLength** | `number` || |
59-
| **requireUppercase** | `boolean` || |
60-
| **requireLowercase** | `boolean` || |
61-
| **requireNumbers** | `boolean` || |
62-
| **requireSymbols** | `boolean` || |
63-
| **expirationDays** | `number` | optional | Force password change every X days |
64-
| **historyCount** | `number` || Prevent reusing last X passwords |
58+
| **minLength** | `number` || [EXPERIMENTAL — not enforced] Minimum password length |
59+
| **requireUppercase** | `boolean` || [EXPERIMENTAL — not enforced] Require an uppercase letter |
60+
| **requireLowercase** | `boolean` || [EXPERIMENTAL — not enforced] Require a lowercase letter |
61+
| **requireNumbers** | `boolean` || [EXPERIMENTAL — not enforced] Require a number |
62+
| **requireSymbols** | `boolean` || [EXPERIMENTAL — not enforced] Require a symbol |
63+
| **expirationDays** | `number` | optional | [EXPERIMENTAL — not enforced] Force password change every X days |
64+
| **historyCount** | `number` || [EXPERIMENTAL — not enforced] Prevent reusing last X passwords |
6565

6666

6767
---
@@ -77,8 +77,8 @@ const result = AuditPolicy.parse(data);
7777
| **network** | `Object` | optional | |
7878
| **session** | `Object` | optional | |
7979
| **audit** | `Object` | optional | |
80-
| **isDefault** | `boolean` || Apply to all users by default |
81-
| **assignedProfiles** | `string[]` | optional | Apply to specific profiles |
80+
| **isDefault** | `boolean` || [EXPERIMENTAL — not enforced] Apply to all users by default |
81+
| **assignedProfiles** | `string[]` | optional | [EXPERIMENTAL — not enforced] Apply to specific profiles |
8282

8383

8484
---
@@ -89,9 +89,9 @@ const result = AuditPolicy.parse(data);
8989

9090
| Property | Type | Required | Description |
9191
| :--- | :--- | :--- | :--- |
92-
| **idleTimeout** | `number` || Minutes before idle session logout |
93-
| **absoluteTimeout** | `number` || Max session duration (minutes) |
94-
| **forceMfa** | `boolean` || Require 2FA for all users |
92+
| **idleTimeout** | `number` || [EXPERIMENTAL — not enforced] Minutes before idle session logout |
93+
| **absoluteTimeout** | `number` || [EXPERIMENTAL — not enforced] Max session duration (minutes) |
94+
| **forceMfa** | `boolean` || [EXPERIMENTAL — not enforced] Require 2FA for all users |
9595

9696

9797
---

packages/spec/liveness/security.json

Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
{
22
"category": "security",
3-
"_note": "Liveness classification for authorable security metadata. Seeded from docs/audits/2026-06-security-identity-property-liveness.md (file:line evidence) plus targeted greps for schemas the audit did not cover. Statuses: live | experimental | planned | dead | (schema-level) internal. 'dead' = parsed, no runtime consumer → enforce-or-remove (security ADR). Security-critical: a 'dead' boundary prop = false compliance.",
3+
"_note": "Liveness classification for authorable security metadata. Seeded from docs/audits/2026-06-security-identity-property-liveness.md (file:line evidence) + greps, reconciled with ADR-0049 dispositions. Where the spec `.describe()` already carries an `[EXPERIMENTAL — not enforced]` marker, that drives the status and the prop is omitted here (marker is the source of truth). This ledger carries: live+evidence, dead-with-no-roadmap (removal candidates), internal exemptions, and sub-object refs that have no per-prop marker. Statuses: live | experimental | planned | dead | (schema-level) internal.",
44
"schemas": {
55
"ObjectPermission": {
6+
"_note": "allowTransfer/allowRestore/allowPurge are marker-driven (experimental, ADR-0049 #1883) — destructive ops not in OPERATION_TO_PERMISSION; PermissionEvaluator now fails CLOSED for them (permission-evaluator.ts:27,45-51).",
67
"props": {
78
"allowCreate": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" },
89
"allowRead": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" },
910
"allowEdit": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" },
1011
"allowDelete": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" },
11-
"viewAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts" },
12-
"modifyAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts" },
13-
"allowTransfer": { "status": "dead", "evidence": "permission-evaluator.ts:8-16 (absent from OPERATION_TO_PERMISSION)", "note": "destructive op (ownership transfer) NOT gated by RBAC — enforce-or-remove. Spec marks experimental." },
14-
"allowRestore": { "status": "dead", "evidence": "permission-evaluator.ts:8-16 (absent)", "note": "undelete NOT gated — enforce-or-remove." },
15-
"allowPurge": { "status": "dead", "evidence": "permission-evaluator.ts:8-16 (absent)", "note": "hard-delete/GDPR purge NOT gated — enforce-or-remove." }
12+
"viewAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:64" },
13+
"modifyAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:60" }
1614
}
1715
},
1816
"FieldPermission": {
@@ -23,15 +21,15 @@
2321
},
2422
"PermissionSet": {
2523
"props": {
26-
"name": { "status": "live", "evidence": "permission-evaluator.ts", "note": "registration/assignment key." },
24+
"name": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", "note": "registration/assignment key." },
2725
"label": { "status": "live", "note": "display metadata (admin forms), not a security boundary." },
2826
"objects": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" },
2927
"fields": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts" },
3028
"rowLevelSecurity": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", "note": "enforced on find + analytics raw-SQL." },
31-
"systemPermissions": { "status": "live", "evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts", "note": "PARTIAL — enforced only for app-entry/nav visibility, NOT as a general capability gate (e.g. manage_users unchecked in data path)." },
32-
"tabPermissions": { "status": "live", "note": "PARTIAL — only 'hidden' is read; default_on/default_off ignored; UI-only, not a boundary." },
33-
"isProfile": { "status": "dead", "evidence": "audit: profile-vs-permset never gates anything", "note": "enforce-or-remove." },
34-
"contextVariables": { "status": "dead", "evidence": "rls-compiler.ts never reads it", "note": "RLS uses only current_user.* built-ins; doc claim of runtime evaluation is false." }
29+
"systemPermissions": { "status": "live", "evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts", "note": "PARTIAL — enforced only for app-entry/nav visibility, NOT a general capability gate." },
30+
"tabPermissions": { "status": "live", "note": "PARTIAL — only 'hidden' is read; default_on/default_off ignored; UI-only." },
31+
"isProfile": { "status": "dead", "evidence": "audit: profile-vs-permset never gates anything", "note": "enforce-or-remove (ADR-0049)." },
32+
"contextVariables": { "status": "dead", "evidence": "rls-compiler.ts never reads it", "note": "RLS uses only current_user.* built-ins." }
3533
}
3634
},
3735
"RowLevelSecurityPolicy": {
@@ -50,26 +48,19 @@
5048
}
5149
},
5250
"Policy": {
53-
"_note": "PolicySchema is 100% DEAD — not even registered as a metadata type; better-auth runs its own hardcoded config (packages/plugins/plugin-auth/src/index.ts). Authoring a compliance Policy = false compliance.",
51+
"_note": "PolicySchema is 100% DEAD but roadmapped (ADR-0049 #1882) → marked experimental. Per-prop markers in policy.zod.ts drive name/isDefault/assignedProfiles; the sub-object refs below carry no per-prop describe(), so they're classified here.",
5452
"props": {
55-
"name": { "status": "experimental", "evidence": "spec describe(): [EXPERIMENTAL — not enforced]" },
56-
"password": { "status": "dead", "evidence": "auth: better-auth hardcoded scrypt/session", "note": "false compliance — enforce or remove." },
57-
"network": { "status": "dead", "evidence": "no consumer", "note": "IP allow-list/VPN unenforced." },
58-
"session": { "status": "dead", "evidence": "auth: better-auth hardcoded session", "note": "idle/absolute timeout + forceMfa unenforced." },
59-
"audit": { "status": "dead", "evidence": "no consumer", "note": "retention/redaction unenforced." },
60-
"isDefault": { "status": "dead", "evidence": "no consumer" },
61-
"assignedProfiles": { "status": "dead", "evidence": "no consumer" }
53+
"password": { "status": "experimental", "evidence": "policy.zod.ts (PasswordPolicySchema all marked); better-auth hardcoded" },
54+
"network": { "status": "experimental", "evidence": "policy.zod.ts (NetworkPolicySchema all marked)" },
55+
"session": { "status": "experimental", "evidence": "policy.zod.ts (SessionPolicySchema all marked); forceMfa unenforced" },
56+
"audit": { "status": "experimental", "evidence": "policy.zod.ts (AuditPolicySchema all marked)" }
6257
}
6358
},
64-
"PasswordPolicy": { "_schema": "dead", "_note": "Policy.password subtree — better-auth hardcoded; unenforced. enforce-or-remove.", "props": {} },
65-
"SessionPolicy": { "_schema": "dead", "_note": "Policy.session subtree — idle/absoluteTimeout/forceMfa all unenforced; better-auth hardcoded. forceMfa=true is a silent no-op (false compliance).", "props": {} },
66-
"NetworkPolicy": { "_schema": "dead", "_note": "Policy.network subtree — trustedRanges/blockUnknown/vpnRequired unenforced.", "props": {} },
67-
"AuditPolicy": { "_schema": "dead", "_note": "Policy.audit subtree — logRetentionDays/sensitiveFields/captureRead unenforced.", "props": {} },
68-
"OwnerSharingRule": { "_schema": "dead", "_note": "Spec SharingRule has NO runtime consumer; runtime enforces a divergent sys_sharing_rule model (packages/plugins/plugin-sharing/src/sharing-plugin.ts). Authoring the spec rule has no effect — reconcile to one contract.", "props": {} },
69-
"RLSConfig": { "_schema": "dead", "_note": "No runtime consumer (grep). RLS enforcement reads RowLevelSecurityPolicy via rls-compiler.ts, not this config object.", "props": {} },
70-
"RLSAuditConfig": { "_schema": "dead", "_note": "No runtime consumer (grep).", "props": {} },
71-
"Territory": { "_schema": "dead", "_note": "Salesforce-style territory management — no runtime consumer (grep).", "props": {} },
72-
"TerritoryModel": { "_schema": "dead", "_note": "No runtime consumer (grep).", "props": {} },
59+
"OwnerSharingRule": { "_schema": "experimental", "_note": "ADR-0049 #1887: spec SharingRule disconnected from the live sys_sharing_rule engine (packages/plugins/plugin-sharing/src/sharing-plugin.ts); marked experimental pending reconciliation (M2). Schema-level JSDoc in sharing.zod.ts.", "props": {} },
60+
"RLSConfig": { "_schema": "dead", "_note": "No runtime consumer (grep). RLS reads RowLevelSecurityPolicy via rls-compiler.ts, not this config. Removal candidate (no roadmap).", "props": {} },
61+
"RLSAuditConfig": { "_schema": "dead", "_note": "No runtime consumer (grep). Removal candidate.", "props": {} },
62+
"Territory": { "_schema": "dead", "_note": "Salesforce-style territory mgmt — no runtime consumer (grep). Removal candidate (no roadmap).", "props": {} },
63+
"TerritoryModel": { "_schema": "dead", "_note": "No runtime consumer (grep). Removal candidate.", "props": {} },
7364
"RLSEvaluationResult": { "_schema": "internal", "_note": "RLS runtime evaluation result — not authorable metadata." },
7465
"RLSAuditEvent": { "_schema": "internal", "_note": "RLS runtime audit event — not authorable metadata." },
7566
"RLSUserContext": { "_schema": "internal", "_note": "RLS runtime user context — not authorable metadata." }

packages/spec/src/security/policy.zod.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,52 @@
22

33
import { z } from 'zod';
44

5+
// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0049, #1882). The entire PolicySchema tree
6+
// (password / network / session / audit) is parsed but has no runtime consumer;
7+
// `better-auth` runs hardcoded defaults regardless. Every property below carries
8+
// the `[EXPERIMENTAL — not enforced]` marker so the no-op is explicit in the
9+
// generated reference docs and to the spec-liveness gate — authoring any of these
10+
// does NOT change behaviour. Do not rely on them for compliance.
11+
512
/**
613
* Password Complexity Policy
714
*/
815
import { lazySchema } from '../shared/lazy-schema';
916
export const PasswordPolicySchema = lazySchema(() => z.object({
10-
minLength: z.number().default(8),
11-
requireUppercase: z.boolean().default(true),
12-
requireLowercase: z.boolean().default(true),
13-
requireNumbers: z.boolean().default(true),
14-
requireSymbols: z.boolean().default(false),
15-
expirationDays: z.number().optional().describe('Force password change every X days'),
16-
historyCount: z.number().default(3).describe('Prevent reusing last X passwords'),
17+
minLength: z.number().default(8).describe('[EXPERIMENTAL — not enforced] Minimum password length'),
18+
requireUppercase: z.boolean().default(true).describe('[EXPERIMENTAL — not enforced] Require an uppercase letter'),
19+
requireLowercase: z.boolean().default(true).describe('[EXPERIMENTAL — not enforced] Require a lowercase letter'),
20+
requireNumbers: z.boolean().default(true).describe('[EXPERIMENTAL — not enforced] Require a number'),
21+
requireSymbols: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Require a symbol'),
22+
expirationDays: z.number().optional().describe('[EXPERIMENTAL — not enforced] Force password change every X days'),
23+
historyCount: z.number().default(3).describe('[EXPERIMENTAL — not enforced] Prevent reusing last X passwords'),
1724
}));
1825

1926
/**
2027
* Network Access Policy (IP Whitelisting)
2128
*/
2229
export const NetworkPolicySchema = lazySchema(() => z.object({
23-
trustedRanges: z.array(z.string()).describe('CIDR ranges allowed to access (e.g. 10.0.0.0/8)'),
24-
blockUnknown: z.boolean().default(false).describe('Block all IPs not in trusted ranges'),
25-
vpnRequired: z.boolean().default(false),
30+
trustedRanges: z.array(z.string()).describe('[EXPERIMENTAL — not enforced] CIDR ranges allowed to access (e.g. 10.0.0.0/8)'),
31+
blockUnknown: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Block all IPs not in trusted ranges'),
32+
vpnRequired: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Require VPN to access'),
2633
}));
2734

2835
/**
2936
* Session Policy
3037
*/
3138
export const SessionPolicySchema = lazySchema(() => z.object({
32-
idleTimeout: z.number().default(30).describe('Minutes before idle session logout'),
33-
absoluteTimeout: z.number().default(480).describe('Max session duration (minutes)'),
34-
forceMfa: z.boolean().default(false).describe('Require 2FA for all users'),
39+
idleTimeout: z.number().default(30).describe('[EXPERIMENTAL — not enforced] Minutes before idle session logout'),
40+
absoluteTimeout: z.number().default(480).describe('[EXPERIMENTAL — not enforced] Max session duration (minutes)'),
41+
forceMfa: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Require 2FA for all users'),
3542
}));
3643

3744
/**
3845
* Audit Retention Policy
3946
*/
4047
export const AuditPolicySchema = lazySchema(() => z.object({
41-
logRetentionDays: z.number().default(180),
42-
sensitiveFields: z.array(z.string()).describe('Fields to redact in logs (e.g. password, ssn)'),
43-
captureRead: z.boolean().default(false).describe('Log read access (High volume!)'),
48+
logRetentionDays: z.number().default(180).describe('[EXPERIMENTAL — not enforced] Days to retain audit logs'),
49+
sensitiveFields: z.array(z.string()).describe('[EXPERIMENTAL — not enforced] Fields to redact in logs (e.g. password, ssn)'),
50+
captureRead: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Log read access (High volume!)'),
4451
}));
4552

4653
/**
@@ -58,15 +65,15 @@ export const AuditPolicySchema = lazySchema(() => z.object({
5865
*/
5966
export const PolicySchema = lazySchema(() => z.object({
6067
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('[EXPERIMENTAL — not enforced] Policy Name'),
61-
68+
6269
password: PasswordPolicySchema.optional(),
6370
network: NetworkPolicySchema.optional(),
6471
session: SessionPolicySchema.optional(),
6572
audit: AuditPolicySchema.optional(),
6673

6774
/** Assignment */
68-
isDefault: z.boolean().default(false).describe('Apply to all users by default'),
69-
assignedProfiles: z.array(z.string()).optional().describe('Apply to specific profiles'),
75+
isDefault: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Apply to all users by default'),
76+
assignedProfiles: z.array(z.string()).optional().describe('[EXPERIMENTAL — not enforced] Apply to specific profiles'),
7077
}));
7178

7279
export type Policy = z.infer<typeof PolicySchema>;

0 commit comments

Comments
 (0)