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
5 changes: 5 additions & 0 deletions .changeset/policy-experimental-markers.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 18 additions & 18 deletions content/docs/references/security/policy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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!) |


---
Expand All @@ -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 |


---
Expand All @@ -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 |


---
Expand All @@ -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 |


---
Expand All @@ -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 |


---
Expand Down
47 changes: 19 additions & 28 deletions packages/spec/liveness/security.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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": {
Expand All @@ -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." }
Expand Down
45 changes: 26 additions & 19 deletions packages/spec/src/security/policy.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!)'),
}));

/**
Expand All @@ -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<typeof PolicySchema>;
Loading