From 9aa4ca93fecf36da62f8391bd78401137590c6fb Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Mon, 15 Jun 2026 23:46:02 +0800 Subject: [PATCH] =?UTF-8?q?chore(spec):=20mark=20PolicySchema=20properties?= =?UTF-8?q?=20experimental=20=E2=80=94=20kill=20false=20compliance=20(ADR-?= =?UTF-8?q?0049=20#1882)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0049's "mark experimental now" disposition for the dangling PolicySchema was only half-applied: the top-of-file JSDoc and `name` were marked, but the actual sub-properties (password complexity, session idle/absolute timeout, `forceMfa`, IP allow-list, audit retention/redaction) still had plain `.describe()` text. So the generated reference docs showed e.g. `forceMfa` as "Require 2FA for all users" with no caveat — a false-compliance signal for a property that enforces nothing (better-auth runs hardcoded defaults). This propagates `[EXPERIMENTAL — not enforced]` to every PolicySchema property so the no-op is explicit in the published docs and to the spec-liveness gate. Description-only — no behaviour change. Liveness gate (security): 26 live / 35 experimental / 32 dead, 0 unclassified. The PolicySchema tree moves dead→experimental (marker-driven); the remaining 32 dead (isProfile, contextVariables, SharingRule's runtime divergence, and the no-roadmap Territory/RLSConfig/RLSAuditConfig removal candidates) stay as the enforce-or-remove worklist. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/policy-experimental-markers.md | 5 +++ content/docs/references/security/policy.mdx | 36 ++++++++-------- packages/spec/liveness/security.json | 47 +++++++++------------ packages/spec/src/security/policy.zod.ts | 45 +++++++++++--------- 4 files changed, 68 insertions(+), 65 deletions(-) create mode 100644 .changeset/policy-experimental-markers.md diff --git a/.changeset/policy-experimental-markers.md b/.changeset/policy-experimental-markers.md new file mode 100644 index 0000000000..b4482b2af1 --- /dev/null +++ b/.changeset/policy-experimental-markers.md @@ -0,0 +1,5 @@ +--- +"@objectstack/spec": patch +--- + +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. diff --git a/content/docs/references/security/policy.mdx b/content/docs/references/security/policy.mdx index 2748e665eb..ea6ac2b805 100644 --- a/content/docs/references/security/policy.mdx +++ b/content/docs/references/security/policy.mdx @@ -29,9 +29,9 @@ const result = AuditPolicy.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **logRetentionDays** | `number` | ✅ | | -| **sensitiveFields** | `string[]` | ✅ | Fields to redact in logs (e.g. password, ssn) | -| **captureRead** | `boolean` | ✅ | Log read access (High volume!) | +| **logRetentionDays** | `number` | ✅ | [EXPERIMENTAL — not enforced] Days to retain audit logs | +| **sensitiveFields** | `string[]` | ✅ | [EXPERIMENTAL — not enforced] Fields to redact in logs (e.g. password, ssn) | +| **captureRead** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Log read access (High volume!) | --- @@ -42,9 +42,9 @@ const result = AuditPolicy.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **trustedRanges** | `string[]` | ✅ | CIDR ranges allowed to access (e.g. 10.0.0.0/8) | -| **blockUnknown** | `boolean` | ✅ | Block all IPs not in trusted ranges | -| **vpnRequired** | `boolean` | ✅ | | +| **trustedRanges** | `string[]` | ✅ | [EXPERIMENTAL — not enforced] CIDR ranges allowed to access (e.g. 10.0.0.0/8) | +| **blockUnknown** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Block all IPs not in trusted ranges | +| **vpnRequired** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Require VPN to access | --- @@ -55,13 +55,13 @@ const result = AuditPolicy.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **minLength** | `number` | ✅ | | -| **requireUppercase** | `boolean` | ✅ | | -| **requireLowercase** | `boolean` | ✅ | | -| **requireNumbers** | `boolean` | ✅ | | -| **requireSymbols** | `boolean` | ✅ | | -| **expirationDays** | `number` | optional | Force password change every X days | -| **historyCount** | `number` | ✅ | Prevent reusing last X passwords | +| **minLength** | `number` | ✅ | [EXPERIMENTAL — not enforced] Minimum password length | +| **requireUppercase** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Require an uppercase letter | +| **requireLowercase** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Require a lowercase letter | +| **requireNumbers** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Require a number | +| **requireSymbols** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Require a symbol | +| **expirationDays** | `number` | optional | [EXPERIMENTAL — not enforced] Force password change every X days | +| **historyCount** | `number` | ✅ | [EXPERIMENTAL — not enforced] Prevent reusing last X passwords | --- @@ -77,8 +77,8 @@ const result = AuditPolicy.parse(data); | **network** | `Object` | optional | | | **session** | `Object` | optional | | | **audit** | `Object` | optional | | -| **isDefault** | `boolean` | ✅ | Apply to all users by default | -| **assignedProfiles** | `string[]` | optional | Apply to specific profiles | +| **isDefault** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Apply to all users by default | +| **assignedProfiles** | `string[]` | optional | [EXPERIMENTAL — not enforced] Apply to specific profiles | --- @@ -89,9 +89,9 @@ const result = AuditPolicy.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **idleTimeout** | `number` | ✅ | Minutes before idle session logout | -| **absoluteTimeout** | `number` | ✅ | Max session duration (minutes) | -| **forceMfa** | `boolean` | ✅ | Require 2FA for all users | +| **idleTimeout** | `number` | ✅ | [EXPERIMENTAL — not enforced] Minutes before idle session logout | +| **absoluteTimeout** | `number` | ✅ | [EXPERIMENTAL — not enforced] Max session duration (minutes) | +| **forceMfa** | `boolean` | ✅ | [EXPERIMENTAL — not enforced] Require 2FA for all users | --- diff --git a/packages/spec/liveness/security.json b/packages/spec/liveness/security.json index 37edc90a88..93a3109fd9 100644 --- a/packages/spec/liveness/security.json +++ b/packages/spec/liveness/security.json @@ -1,18 +1,16 @@ { "category": "security", - "_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.", + "_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.", "schemas": { "ObjectPermission": { + "_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).", "props": { "allowCreate": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, "allowRead": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, "allowEdit": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, "allowDelete": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, - "viewAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts" }, - "modifyAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts" }, - "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." }, - "allowRestore": { "status": "dead", "evidence": "permission-evaluator.ts:8-16 (absent)", "note": "undelete NOT gated — enforce-or-remove." }, - "allowPurge": { "status": "dead", "evidence": "permission-evaluator.ts:8-16 (absent)", "note": "hard-delete/GDPR purge NOT gated — enforce-or-remove." } + "viewAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:64" }, + "modifyAllRecords": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:60" } } }, "FieldPermission": { @@ -23,15 +21,15 @@ }, "PermissionSet": { "props": { - "name": { "status": "live", "evidence": "permission-evaluator.ts", "note": "registration/assignment key." }, + "name": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", "note": "registration/assignment key." }, "label": { "status": "live", "note": "display metadata (admin forms), not a security boundary." }, "objects": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, "fields": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts" }, "rowLevelSecurity": { "status": "live", "evidence": "packages/plugins/plugin-security/src/rls-compiler.ts", "note": "enforced on find + analytics raw-SQL." }, - "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)." }, - "tabPermissions": { "status": "live", "note": "PARTIAL — only 'hidden' is read; default_on/default_off ignored; UI-only, not a boundary." }, - "isProfile": { "status": "dead", "evidence": "audit: profile-vs-permset never gates anything", "note": "enforce-or-remove." }, - "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." } + "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." }, + "tabPermissions": { "status": "live", "note": "PARTIAL — only 'hidden' is read; default_on/default_off ignored; UI-only." }, + "isProfile": { "status": "dead", "evidence": "audit: profile-vs-permset never gates anything", "note": "enforce-or-remove (ADR-0049)." }, + "contextVariables": { "status": "dead", "evidence": "rls-compiler.ts never reads it", "note": "RLS uses only current_user.* built-ins." } } }, "RowLevelSecurityPolicy": { @@ -50,26 +48,19 @@ } }, "Policy": { - "_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.", + "_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.", "props": { - "name": { "status": "experimental", "evidence": "spec describe(): [EXPERIMENTAL — not enforced]" }, - "password": { "status": "dead", "evidence": "auth: better-auth hardcoded scrypt/session", "note": "false compliance — enforce or remove." }, - "network": { "status": "dead", "evidence": "no consumer", "note": "IP allow-list/VPN unenforced." }, - "session": { "status": "dead", "evidence": "auth: better-auth hardcoded session", "note": "idle/absolute timeout + forceMfa unenforced." }, - "audit": { "status": "dead", "evidence": "no consumer", "note": "retention/redaction unenforced." }, - "isDefault": { "status": "dead", "evidence": "no consumer" }, - "assignedProfiles": { "status": "dead", "evidence": "no consumer" } + "password": { "status": "experimental", "evidence": "policy.zod.ts (PasswordPolicySchema all marked); better-auth hardcoded" }, + "network": { "status": "experimental", "evidence": "policy.zod.ts (NetworkPolicySchema all marked)" }, + "session": { "status": "experimental", "evidence": "policy.zod.ts (SessionPolicySchema all marked); forceMfa unenforced" }, + "audit": { "status": "experimental", "evidence": "policy.zod.ts (AuditPolicySchema all marked)" } } }, - "PasswordPolicy": { "_schema": "dead", "_note": "Policy.password subtree — better-auth hardcoded; unenforced. enforce-or-remove.", "props": {} }, - "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": {} }, - "NetworkPolicy": { "_schema": "dead", "_note": "Policy.network subtree — trustedRanges/blockUnknown/vpnRequired unenforced.", "props": {} }, - "AuditPolicy": { "_schema": "dead", "_note": "Policy.audit subtree — logRetentionDays/sensitiveFields/captureRead unenforced.", "props": {} }, - "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": {} }, - "RLSConfig": { "_schema": "dead", "_note": "No runtime consumer (grep). RLS enforcement reads RowLevelSecurityPolicy via rls-compiler.ts, not this config object.", "props": {} }, - "RLSAuditConfig": { "_schema": "dead", "_note": "No runtime consumer (grep).", "props": {} }, - "Territory": { "_schema": "dead", "_note": "Salesforce-style territory management — no runtime consumer (grep).", "props": {} }, - "TerritoryModel": { "_schema": "dead", "_note": "No runtime consumer (grep).", "props": {} }, + "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": {} }, + "RLSConfig": { "_schema": "dead", "_note": "No runtime consumer (grep). RLS reads RowLevelSecurityPolicy via rls-compiler.ts, not this config. Removal candidate (no roadmap).", "props": {} }, + "RLSAuditConfig": { "_schema": "dead", "_note": "No runtime consumer (grep). Removal candidate.", "props": {} }, + "Territory": { "_schema": "dead", "_note": "Salesforce-style territory mgmt — no runtime consumer (grep). Removal candidate (no roadmap).", "props": {} }, + "TerritoryModel": { "_schema": "dead", "_note": "No runtime consumer (grep). Removal candidate.", "props": {} }, "RLSEvaluationResult": { "_schema": "internal", "_note": "RLS runtime evaluation result — not authorable metadata." }, "RLSAuditEvent": { "_schema": "internal", "_note": "RLS runtime audit event — not authorable metadata." }, "RLSUserContext": { "_schema": "internal", "_note": "RLS runtime user context — not authorable metadata." } diff --git a/packages/spec/src/security/policy.zod.ts b/packages/spec/src/security/policy.zod.ts index 6a087b554a..1ac0e1033a 100644 --- a/packages/spec/src/security/policy.zod.ts +++ b/packages/spec/src/security/policy.zod.ts @@ -2,45 +2,52 @@ import { z } from 'zod'; +// ⚠️ EXPERIMENTAL — NOT ENFORCED (ADR-0049, #1882). The entire PolicySchema tree +// (password / network / session / audit) is parsed but has no runtime consumer; +// `better-auth` runs hardcoded defaults regardless. Every property below carries +// the `[EXPERIMENTAL — not enforced]` marker so the no-op is explicit in the +// generated reference docs and to the spec-liveness gate — authoring any of these +// does NOT change behaviour. Do not rely on them for compliance. + /** * Password Complexity Policy */ import { lazySchema } from '../shared/lazy-schema'; export const PasswordPolicySchema = lazySchema(() => z.object({ - minLength: z.number().default(8), - requireUppercase: z.boolean().default(true), - requireLowercase: z.boolean().default(true), - requireNumbers: z.boolean().default(true), - requireSymbols: z.boolean().default(false), - expirationDays: z.number().optional().describe('Force password change every X days'), - historyCount: z.number().default(3).describe('Prevent reusing last X passwords'), + minLength: z.number().default(8).describe('[EXPERIMENTAL — not enforced] Minimum password length'), + requireUppercase: z.boolean().default(true).describe('[EXPERIMENTAL — not enforced] Require an uppercase letter'), + requireLowercase: z.boolean().default(true).describe('[EXPERIMENTAL — not enforced] Require a lowercase letter'), + requireNumbers: z.boolean().default(true).describe('[EXPERIMENTAL — not enforced] Require a number'), + requireSymbols: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Require a symbol'), + expirationDays: z.number().optional().describe('[EXPERIMENTAL — not enforced] Force password change every X days'), + historyCount: z.number().default(3).describe('[EXPERIMENTAL — not enforced] Prevent reusing last X passwords'), })); /** * Network Access Policy (IP Whitelisting) */ export const NetworkPolicySchema = lazySchema(() => z.object({ - trustedRanges: z.array(z.string()).describe('CIDR ranges allowed to access (e.g. 10.0.0.0/8)'), - blockUnknown: z.boolean().default(false).describe('Block all IPs not in trusted ranges'), - vpnRequired: z.boolean().default(false), + trustedRanges: z.array(z.string()).describe('[EXPERIMENTAL — not enforced] CIDR ranges allowed to access (e.g. 10.0.0.0/8)'), + blockUnknown: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Block all IPs not in trusted ranges'), + vpnRequired: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Require VPN to access'), })); /** * Session Policy */ export const SessionPolicySchema = lazySchema(() => z.object({ - idleTimeout: z.number().default(30).describe('Minutes before idle session logout'), - absoluteTimeout: z.number().default(480).describe('Max session duration (minutes)'), - forceMfa: z.boolean().default(false).describe('Require 2FA for all users'), + idleTimeout: z.number().default(30).describe('[EXPERIMENTAL — not enforced] Minutes before idle session logout'), + absoluteTimeout: z.number().default(480).describe('[EXPERIMENTAL — not enforced] Max session duration (minutes)'), + forceMfa: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Require 2FA for all users'), })); /** * Audit Retention Policy */ export const AuditPolicySchema = lazySchema(() => z.object({ - logRetentionDays: z.number().default(180), - sensitiveFields: z.array(z.string()).describe('Fields to redact in logs (e.g. password, ssn)'), - captureRead: z.boolean().default(false).describe('Log read access (High volume!)'), + logRetentionDays: z.number().default(180).describe('[EXPERIMENTAL — not enforced] Days to retain audit logs'), + sensitiveFields: z.array(z.string()).describe('[EXPERIMENTAL — not enforced] Fields to redact in logs (e.g. password, ssn)'), + captureRead: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Log read access (High volume!)'), })); /** @@ -58,15 +65,15 @@ export const AuditPolicySchema = lazySchema(() => z.object({ */ export const PolicySchema = lazySchema(() => z.object({ name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('[EXPERIMENTAL — not enforced] Policy Name'), - + password: PasswordPolicySchema.optional(), network: NetworkPolicySchema.optional(), session: SessionPolicySchema.optional(), audit: AuditPolicySchema.optional(), /** Assignment */ - isDefault: z.boolean().default(false).describe('Apply to all users by default'), - assignedProfiles: z.array(z.string()).optional().describe('Apply to specific profiles'), + isDefault: z.boolean().default(false).describe('[EXPERIMENTAL — not enforced] Apply to all users by default'), + assignedProfiles: z.array(z.string()).optional().describe('[EXPERIMENTAL — not enforced] Apply to specific profiles'), })); export type Policy = z.infer;